Skip to content

Sending a subscription to TV

Move a subscription to Apple TV / Android TV from a phone — from any network, even when the devices are in different parts of the city.

The key property: the relay cannot read what passes through it. The subscription link is encrypted on the phone and decrypted only on the TV. The server sees just two opaque blobs of bytes.

The code is fully open — you can verify the server really decrypts nothing, and run your own relay if you prefer.


How it works

An 8-character code appears on the TV. You type it on the phone (or scan the QR) — and the subscription moves across.

TV                        Relay                     Phone
 │                          │                         │
 │  claims a code           │                         │
 │─────────────────────────>│                         │
 │                          │                         │
 │  shows                   │   reads parameters      │
 │  K7M2XQ4P + QR           │<────────────────────────│
 │                          │                         │
 │                          │   encrypts subscription │
 │                          │   with key from code    │
 │                          │                         │
 │                          │<─── ciphertext ─────────│
 │<─── ciphertext ──────────│                         │
 │                          │                         │
 │  decrypts                │  record is deleted      │

Both sides derive a shared encryption key from the code alone, using the CPace protocol. The relay, observing the entire exchange, cannot derive that key: it only sees public values from which the secret cannot be recovered.

Why 8 characters is enough

Short codes usually mean weak protection. Not here: even with the traffic captured, an attacker cannot brute-force the code on their own machine. Every attempt requires contacting the server, and the server allows only 5 attempts before destroying the code.

That is why typing the code manually is as safe as scanning the QR — in both cases only the code travels, and the key is computed on the devices.


What the server sees

Data Stored Note
Subscription link no encrypted, server has no key
Provider name no inside the same ciphertext
Device IP addresses no not written to storage or logs
Transfer history no record deleted right after delivery
Two blobs of bytes 5 minutes then removed automatically

Request bodies are not logged — they contain ciphertext, but logs tend to leak, so they simply do not exist.


Local transfer without the internet

When the phone and TV are on the same Wi-Fi, the app uses a direct connection (Bonjour, _incy-tv._tcp) — the data never leaves the home network and never touches the server.

The relay kicks in only when a direct connection is impossible. The user is not shown the difference — it just works either way.


API for integrations

Your bots and panels can push a subscription to a user's TV the same way. All bodies are JSON; binary fields are base64url without padding.

Claim a code

Called by the TV.

POST /pair/init
Content-Type: application/json

{ "code": "K7M2XQ4P", "sid": "<16 bytes>", "ya": "<32 bytes>" }
Response Meaning
204 accepted
409 code taken — generate a new one and retry
400 malformed code or point

Read parameters

Called by the sender.

GET /pair/K7M2XQ4P
{ "sid": "<16 bytes>", "ya": "<32 bytes>" }
Response Meaning
200 parameters retrieved
404 code missing or expired
429 attempt limit exceeded, code destroyed

Every call counts as an attempt

This is the brute-force protection. After 5 calls the code burns — do not poll this endpoint in a loop.

Send the subscription

POST /pair/K7M2XQ4P/send
Content-Type: application/json

{ "yb": "<32 bytes>", "ct": "<nonce+ciphertext+tag>" }
Response Meaning
204 accepted
404 code missing or expired
409 subscription already sent (one-shot)
413 body larger than 64 KiB

Collect the subscription

Called by the TV. The request hangs up to 30 seconds waiting for the sender.

GET /pair/K7M2XQ4P/result
Response Meaning
200 { "yb": …, "ct": … }, record deleted immediately
204 nothing yet — repeat the request
404 code expired

Service endpoints

GET /healthz — is the service alive. GET /readyz — is it ready to serve.


Limits

What Limit
Attempts per code 5, then the code is destroyed
Requests per IP 300 per minute
Body size 64 KiB
Code lifetime 5 minutes

The per-code limit matters more than the per-IP one: switching addresses does not help, which is what makes brute force pointless.

There is no separate limit on code creation: hundreds of devices may share one external address (carrier CGNAT, a dormitory), and an hourly cap would cut off some users.


What to send

Inside the ciphertext — JSON:

{
  "v": 1,
  "type": "subscription",
  "url": "https://example.com/sub",
  "name": "My Provider"
}

The ct field is nonce ‖ ciphertext ‖ tag in one piece — the combined layout from CryptoKit, which libsodium reads directly. There is no separate nonce field.

Send the subscription link, not an expanded config: the TV will fetch it itself and get the current server list. That is orders of magnitude smaller and always up to date.


Cryptography

For compatibility with the app the parameters must match byte for byte.

Parameter Value
Group Ristretto255
Protocol CPace
Encryption ChaCha20-Poly1305
Key derivation HKDF-SHA256
Code alphabet ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (no I, O, 0, 1)
generator = ristretto255_from_hash( SHA-512(dsi ‖ code ‖ sid) )
scalar    = ristretto255_scalar_reduce( 64 random bytes )
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"

Points are sorted lexicographically so both sides hash them in the same order without negotiating who goes "first".

Test vector for verifying an implementation:

generator("TESTCODE", sid = 000102…0f) =
def51453cb5cfdb7d78e667cf7575060841474e063f5e39ea28f14fd9340042f

Libraries: libsodium (iOS/tvOS — swift-sodium), lazysodium (Android — com.goterl:lazysodium-android). Ristretto255 is available in both without hand-written bindings.

Verify compatibility with a test

A key mismatch between platforms means the transfer simply will not work. Check the test vector above — that is faster than debugging "it won't decrypt".


Example: Python bot

Sending a subscription to a user's TV by the code they typed.

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. Read the TV parameters. This counts as an attempt — do not retry
    #    on failure, the code burns after 5 of them.
    r = httpx.get(f"{BASE}/pair/{code}", timeout=10)
    if r.status_code == 404:
        raise ValueError("code not found or expired")
    if r.status_code == 429:
        raise ValueError("code locked after failed attempts")
    r.raise_for_status()
    sid, ya = b64d(r.json()["sid"]), b64d(r.json()["ya"])

    # 2. Derive the shared key from the code — the server cannot do this.
    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])          # same point order on both sides
    key = HKDF(hashes.SHA256(), 32, sid, DSI).derive(shared + lo + hi)

    # 3. Encrypt and send.
    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("a subscription was already sent to this code")
    resp.raise_for_status()

Dependencies: pynacl, cryptography, httpx.


Example: Node.js bot

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. TV parameters (one attempt — the per-code limit is 5).
  const r = await fetch(`${BASE}/pair/${code}`);
  if (r.status === 404) throw new Error('code not found or expired');
  if (r.status === 429) throw new Error('code locked');
  if (!r.ok) throw new Error(`relay returned ${r.status}`);
  const { sid: sidB64, ya: yaB64 } = await r.json();
  const sid = b64d(sidB64); const ya = b64d(yaB64);

  // 2. The shared key is derived on the devices, not on the server.
  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);

  // Lexicographic point order — identical on both sides.
  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. Encrypt and send.
  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('already sent to this code');
  if (!resp.ok) throw new Error(`relay returned ${resp.status}`);
}

Dependency: libsodium-wrappers-sumo (the regular libsodium-wrappers build does not include Ristretto255 functions).


Common errors

404 on every request. The code expired — it lives 5 minutes. Ask the user to reopen the add-subscription screen on the TV.

429 on the very first call. Someone was already guessing this code and it was destroyed. A fresh code from the TV is needed.

TV says "invalid code". The MAC did not match: either the code was mistyped, or your crypto implementation diverges. Check the test vector.

409 when sending. A subscription was already sent to this code — it is one-shot.

Empty 204 from /result. That is normal: the sender has not posted yet. Repeat the request; the connection is held for up to 30 seconds.


Running your own relay

The server stores nothing persistent, so it is easy to self-host:

git clone https://github.com/INCY-DEV/incy-tv-relay
cd incy-tv-relay
npm install
REDIS_URL=redis://localhost:6379 npm start

Redis is the only dependency. The repository ships Kubernetes manifests and a Dockerfile.

The app uses check.incytv.com by default; your own relay address is set in the build configuration.