All writing

Reverse engineering Samsung's HD+ TVkey Cloud

I traced a paid HD+ channel on a Samsung TV from the dish to the screen. A five-day license and a retry window of a few minutes explain why the channels keep disappearing.

My in-laws' Samsung TV kept losing its paid HD+ channels. To find out why, I copied about 260 MB of files from the TV and followed one channel from the satellite dish, through a key exchange with the cloud, to the screen.

HD+ and TVkey Cloud#

HD+ is Germany's paid satellite service for 25 private channels in HD, such as RTL, ProSieben, SAT.1 and VOX, broadcast from Astra 19.2E. It used to need a CI+ module with a smartcard, which was open to card sharing: the keys of one module passed over the internet to receivers that never paid.

Since around 2020, Samsung Smart TVs ship with TVkey Cloud instead, a conditional access system in software. It is built into Tizen, Samsung's Linux-based TV operating system, and was developed with NAGRA, the Swiss conditional access company. Keys are bound to one device, identified by its DUID (device unique ID), and to a Samsung account; they arrive over HTTPS and stay valid for about five days. With NAGRA's on-chip security (NOCS), the control word that descrambles the video is designed never to appear in a form that could be intercepted.

My in-laws' QN90A from 2021, running Tizen 6.0, lost all private HD channels every so often. The official fixes, logging out of the HD+ app and back in or the troubleshooting PDF for the known "Speicherbug" (memory bug), work because they make the cloud issue new device credentials and fresh keys. I wanted to know why the system breaks at all.

Getting at the files#

With Developer Mode switched on, a Tizen TV opens SDB (Samsung Debug Bridge, the counterpart to Android's adb) on port 26101. The interactive shell is disabled on consumer TVs, but file transfer works:

Shell
$ sdb connect 192.168.2.130:26101
connected to 192.168.2.130:26101

$ sdb capability
intershell_support:disabled
filesync_support:pushpull
platform_version:6.0
sdbd_version:2.2.31

With sdb pull I copied the user-accessible paths, chiefly /home/owner/ and parts of /opt/usr/, about 260 MB in all. It is not a full firmware dump, and everything below comes from those files.

What runs on the TV#

The conditional access system is called nagsam, from NAGRA and Samsung, and lives in /opt/usr/apps/org.tizen.nagsam/. Its core, libnagsam-mw.so (6.3 MB), processes ECMs (entitlement control messages, the encrypted key packets in the broadcast) and manages the keys.

Rather than open that native code in Ghidra, I read the layer above it: the HD+ operator app, a React application running as an HbbTV OpApp, which is the operator's own web app on the TV. It handles key requests, retries, errors and a hidden debug interface, and reaches the middleware through the OIPF DRM Agent API with the CA system ID urn:dvb:casystemid:19300. Version 3.1.12-prod, build 10282, consists of six webpack chunks totalling 16.7 MB, minified but not obfuscated.

From the dish to the screen#

The video still arrives by satellite in full quality. Only the ECM goes over the internet, and the control words come back once the server has checked the account token, the DUID and the product entitlement.

The path of a paid channel
Satellite (Astra 19.2E)
  ↓  encrypted TS + ECMs
TV tuner
  ↓
nagsam middleware  ← extracts the ECM from the transport stream
  ↓  ECM
Transfer agent  →  HTTPS POST  →  be.tvengine.hd-plus-cloud.de
  ↓                                /license/pcs/online/uniqueEntitlement/v1
  ↓                                checks: account JWT, DUID, product 3050
  ↓  control words
Descrambler
  ↓  clear TS
Display

A license that lasts five days#

The broadcast license is stored in /opt/usr/apps/org.tizen.nagsam/op/cloud_op_db as an ASN.1 DER blob. Decoded, it names the subscription:

bc_license, decoded
{
  "iad": "13573",
  "productRights": [{
    "productId": 3050,
    "start": "2026-02-16T00:00:00Z",
    "end": "2027-02-16T00:00:00Z",
    "externalProductId": "3050"
  }]
}

Product 3050 is the HD+ annual subscription. Two further ASN.1 date fields set the current validity window of about five days: short enough for extracted keys to expire before they could be redistributed, long enough that the TV need not ask the server on every channel change. When the window closes, the TV has to reach the HD+ server, and this is where the design becomes fragile.

Why the renewal fails#

Thirteen retries, then nothing#

JavaScript
retryTimeoutsMs: [1, 2, 5, 15, 30, 30, 30, 30, 30, 30, 30, 30, 30]

The delays climb from 1 to 30 seconds and stay there, 293 seconds in total, just under five minutes. After that the app gives up until the user intervenes. There is no hourly retry and no background job.

No renewal before expiry#

The license is checked only in hasValidProduct(), which runs on every channel change. No timer renews it ahead of expiry, so the user finds out at the next tune:

JavaScript
if (!this.hasValidProduct()) {
  this.handleExpirationPage();
  return false;
}

Missing dates fail at once#

JavaScript
if (!this.activationRenewalDate) {
  A.Success = false;
  A.Result.ErrorCode = 9;
}
if (!this.licenseRenewalDate) {
  A.Success = false;
  A.Result.ErrorCode = 10;
}

After a firmware update or a license reset these dates may be empty, and the status check then fails at once with no path to recovery.

Network errors end playback#

During playback, ignoreNetworkErrorCloseTerminationS: 5 tolerates network errors for five seconds. Then the player stops, without a retry or a fallback.

How it plays out at home#

A likely sequence runs like this. The license is renewed while the TV sits on the main router, and later the TV roams to a WiFi repeater. When the five days are up, the renewal starts, the TV roams back mid-request and the connection drops. If the roaming continues, all thirteen attempts fail and nagsam stops for good. The next channel change shows "Probleme bei HD+". A reboot sometimes helps, since a new DHCP lease brings a new renewal attempt.

Support codes in the app#

The operator app defines secret codes as React defaultProps and listens for them as number sequences on the remote while a satellite channel is on screen.

JavaScript
(0, n.A)(Qs, "defaultProps", {
  scrollAmount: 36,
  secretCodes: {
    unlink: "13795",
    licenseRenew: "59731",
    licenseReset: "95137",
    activationRenew: "73738",
    refreshProduct: "882425"
  }
});

Only 59731 is publicly documented, as a troubleshooting step on the HD+ support portal. I found no public mention of the other four.

CodeActionWhat it does
59731licenseRenewRequests a new license and skips the local cache
73738activationRenewRenews the binding between Samsung account and device
95137licenseResetDeletes the stored license and both renewal dates
882425refreshProductReloads the subscription status
13795unlinkUnlinks a voucher, relevant only for prepaid cards
The five codes defined in the operator app

Code 59731 calls updateLicense(true), and the true bypasses the local cache:

Text
updateLicense(true)
  → opApp.acquireKeys(true)
    → caManager.update("user-request", true)
      → POST https://be.tvengine.hd-plus-cloud.de/license/pcs/online/uniqueEntitlement/v1

It is the same request that failed in the background, started by hand; the server still checks the account, the device and the subscription. The activation renewal sends its requests with empty success and failure callbacks, so nothing reports whether it worked.

Backend and configuration#

EndpointPurpose
be.tvengine.hd-plus-cloud.deMain backend: license exchange and product status
betvip.hd-plus-cloud.deAlternative endpoint with the same API
appreg.hd-plus-cloud.de/api/tv/register/v1Device registration
init-gn.prd.tvengine.hd-plus-cloud.de/satInitial satellite configuration
clientassets.prd.tvengine.hd-plus-cloud.de/opapp/Operator app updates
Azure IoT Hub (MQTT)Push notifications, 12-hour keepalive
Hosts named in the operator app

The file tvkey_list_downloaded.json holds a base64-encoded XML configuration that limits the HD+ installer to TVs with a scanned DVB-S tuner on 19.2E in Germany. Its schema, urn:samsung:TVkeyCloud:smarttv:2018, accepts any operator and country, so TVkey Cloud is not tied to HD+.

Security against reliability#

TVkey Cloud does what it was built for: keys that are device-bound, account-bound, short-lived and checked by the server leave nothing worth sharing. Each of those properties costs reliability:

  • Device-bound keys have no fallback when the server is out of reach.
  • A five-day window is short enough to stop redistribution, and short enough to break during a bad WiFi week.
  • Every server-side check is a network round trip that can fail.
  • The account binding is why logging out and back in helps: it forces new credentials.

The app adds a short retry window, no early renewal and quiet error handling. It is prepared for a reliable connection or a brief outage, while a WiFi repeater produces roaming gaps that are neither.

Method#

AspectDetail
TargetSamsung GQ50QN90AATXZG, Tizen 6.0, firmware T-NKM2DEUC-2301.1
AccessSDB through Developer Mode, port 26101
Extractionsdb pull of /home/owner/, /opt/usr/apps/org.tizen.nagsam/, /opt/usr/data/
AnalysisReading the webpack bundles, ASN.1 decoding, SQLite and LevelDB extraction
ToolsPython 3, sqlite3, SDB 4.2.36 from the Tizen Studio standalone package

I did not reverse engineer the native nagsam libraries; all findings come from the JavaScript app and the data it stores. The architecture is shared by Samsung Smart TVs from 2020 onwards, and code 59731 was the same in every app build I looked at.

The system stops card sharing as intended. What it lacks is patience: a background renewal a day before expiry, and a retry that keeps going past five minutes, would likely spare many households the error screen and the logout routine.