Technical proof · nginx-vod-module · HLS AES-128

A plain MP4, delivered as AES-128 encrypted HLS by nginx-vod-module

The MP4 on the server is not encrypted. For each player request, Kaltura's nginx-vod-module makes the HLS manifest, cuts the segment from the MP4, and encrypts it with AES-128. The Kaltura Player gets the key from a protected URL and decrypts the segments in the browser.

video.mp4Plain H.264 + AAC on disk. No encryption step before storage.
nginx-vod-moduleMakes the manifest and encrypts each .ts segment during the request.
Kaltura Player v7hls.js gets the key from /key and decrypts each segment.
nginx 1.28.3 nginx-vod-module 1.33 Kaltura Player 3.17.97 Source 20 s · 1280×720 · 30 fps · H.264 + AAC Segments 6 × 4 s Server one nginx container
Live

The player on this server

This is the Kaltura Player page at /player. It plays /hls/video.mp4/index.m3u8, which nginx-vod-module makes from the MP4 when the player asks for it. The log under the player shows playing when the decryption works.

Code map

Where the setup is in the code

All the files are in the repository. The line numbers are the lines of the files that run on this server. The highlighted lines do the work.

#File and linesWhat it does
1nginx-vod/Dockerfile:14–25Downloads nginx-vod-module 1.33 and compiles it into nginx 1.28.3 with OpenSSL.
2nginx-vod/nginx.conf.template:31–53The /hls/ location. The module makes the manifest and encrypts each segment with AES-128.
3nginx-vod/nginx.conf.template:55–62The /key location. nginx gives the key only to a request with the token.
4nginx-vod/docker-entrypoint.sh:4–13Makes a random key seed when the container starts and writes it into the nginx configuration.
5public/index.html:36–59The Kaltura Player setup. The player sends the token only on the key request.
6nginx-vod/Dockerfile:36–37Puts the sample MP4 into the image. The file is not encrypted.

1Compile nginx-vod-module into nginx

nginx-vod-module is a normal nginx module. The build downloads the release tag and adds it with --add-module. The module uses OpenSSL for the AES encryption, so --with-http_ssl_module is necessary.

nginx-vod/Dockerfile · lines 14–25
14RUN curl -fsSL https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz | tar xz \15 && curl -fsSL https://github.com/kaltura/nginx-vod-module/archive/refs/tags/${VOD_VERSION}.tar.gz | tar xz16# OpenSSL (--with-http_ssl_module) is necessary for the vod encryption.17# The sub module changes the default manifest URL in the page.18RUN cd nginx-${NGINX_VERSION} \19 && ./configure --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx \20      --conf-path=/etc/nginx/nginx.conf --pid-path=/run/nginx.pid \21      --error-log-path=/dev/stderr --http-log-path=/dev/stdout \22      --with-http_ssl_module --with-http_sub_module --with-threads --with-file-aio \23      --add-module=/src/nginx-vod-module-${VOD_VERSION} \24      --with-cc-opt="-O3" \25 && make -j"$(nproc)" && make install

2Make the manifest and encrypt the segments

These directives are the full encryption setup. There is no encryption step before storage and no encrypted file on disk.

nginx-vod/nginx.conf.template · lines 31–53
31        # nginx-vod-module makes the manifest and the encrypted segments from the MP4 files in /media.32        # Example: /hls/video.mp4/index.m3u8 for /media/video.mp433        location /hls/ {34            # The key is available only through /key. $request_uri is the URL35            # of the client request, so the internal rewrite from /key passes.36            if ($request_uri ~ "/encryption\.key") {37                return 403;38            }3940            vod hls;41            alias /media/;42            vod_mode local;43            vod_segment_duration 4000;44            vod_align_segments_to_key_frames on;45            # Relative segment URLs. Behind a TLS proxy, absolute URLs get http:// and the browser blocks them.46            vod_base_url "";4748            vod_secret_key "${KEY_SECRET}";49            vod_hls_encryption_method aes-128;50            vod_hls_encryption_key_uri "/key";5152            add_header Cache-Control no-store always;53        }
LineDirectiveEffect
40vod hlsThe module answers this location with HLS: index.m3u8, seg-N-v1-a1.ts, and encryption.key.
42vod_mode localThe module reads the MP4 from the local disk. Kaltura production uses mapped, where an API returns the file path.
48vod_secret_keyThe seed of the key. The key is MD5(seed). Location 4 makes the seed.
49vod_hls_encryption_method aes-128Encrypts each segment with AES-128-CBC and PKCS#7 padding. The manifest gets METHOD=AES-128.
50vod_hls_encryption_key_uriChanges the key URL in the manifest from encryption.key to /key, where nginx checks the token.
36–38if … return 403Refuses a direct request for encryption.key. The key is available only through /key.

3Give the key only with the token

nginx checks the Authorization header. With the correct token, an internal rewrite sends the request to the module, and the module returns the 16-byte key. Without the token, the answer is 403.

nginx-vod/nginx.conf.template · lines 55–62
55        # Same contract as server.py: the key only with "Authorization: Bearer <KEY_TOKEN>".56        # The secret does not include the file path, so all files use the same key.57        location = /key {58            if ($http_authorization != "Bearer ${KEY_TOKEN}") {59                return 403;60            }61            rewrite ^ /hls/${VIDEO_FILE}/encryption.key last;62        }

4Make the key seed when the container starts

The seed is random for each container, so the key is never in an image layer. envsubst writes the seed and the token into the configuration of lines 48 and 58.

nginx-vod/docker-entrypoint.sh · lines 4–13
4# Make the key secret once per container. The AES-128 key is the MD5 of this secret.5if [ ! -f /etc/nginx/nginx.conf.generated ]; then6  KEY_SECRET=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')7  export KEY_SECRET8  envsubst '${KEY_SECRET} ${KEY_TOKEN} ${VIDEO_FILE}' < /app/nginx.conf.template > /etc/nginx/nginx.conf9  touch /etc/nginx/nginx.conf.generated10fi1112echo "Open http://127.0.0.1:8766/  (video: /media/${VIDEO_FILE}, key token: ${KEY_TOKEN})"13exec nginx -g 'daemon off;'

5Send the token from the Kaltura Player

The player uses hls.js. xhrSetup adds the token only to the /key request. The manifest and the segments go without credentials.

public/index.html · lines 36–59
36  // The key request is the only request that gets the token.37  let keyToken = document.getElementById('token').value;3839  const player = KalturaPlayer.setup({40    targetId: 'player',41    // Direct source playback: no Kaltura partner is needed.42    // For a Kaltura-hosted entry, set partnerId (and ks) and call player.loadMedia({ entryId }).43    provider: { partnerId: -1 },44    playback: {45      preload: 'auto',                // load manifest, key, and first segment before play46      preferNative: { hls: false },   // use hls.js also in Safari, so xhrSetup runs47      options: {48        html5: {49          hls: {50            xhrSetup: (xhr, url) => {51              if (new URL(url, location.href).pathname === '/key') {52                xhr.setRequestHeader('Authorization', 'Bearer ' + keyToken);53              }54            }55          }56        }57      }58    }59  });

6Store the plain MP4

The image contains the sample MP4 as a normal file. A mount at /media replaces it with other MP4 files.

nginx-vod/Dockerfile · lines 36–37
36# The sample video. A mount at /media replaces it with your MP4 files.37COPY media/video.mp4 /media/video.mp4
Evidence · live

Test this server now

Your browser sends these requests to this server. Then it decrypts segment 1 with the Web Crypto API (AES-CBC). The IV is the segment sequence number, which is the HLS default when the manifest has no IV attribute. An MPEG-TS stream is a chain of 188-byte packets, and each packet starts with the sync byte 0x47.

Running…

As served encrypted

…
Size
…
Packets with 0x47
…

After decryption clear MPEG-TS

…
Size
…
Packets with 0x47
…

Do the test with curl

shell
B=https://kaltura-aes.jrobe.cloud
curl -s $B/hls/video.mp4/index.m3u8
curl -s -o /dev/null -w '%{http_code}\n' $B/key
curl -s -H 'Authorization: Bearer demo-token' -o key.bin $B/key
curl -s -o seg.ts $B/hls/video.mp4/seg-1-v1-a1.ts
openssl aes-128-cbc -d -K "$(xxd -p key.bin)" \
  -iv 00000000000000000000000000000001 -in seg.ts -out clear.ts
ffprobe clear.ts
Changes

What changed in the code for this deployment

The base is commit 5b9124e, where the setup ran only on a local Docker container. These are all the code changes for this server. The encryption directives did not change.

nginx-vod/nginx.conf.template

Relative segment URLs (lines 45–46). The module wrote full segment URLs with the scheme of the request. The HTTPS proxy of the server sends plain HTTP to the container, so the URLs started with http://, and the browser blocked them on this HTTPS page. vod_base_url "" makes the URLs relative.

This page at /, the player at /player (lines 15–29).

git diff 5b9124e -- nginx-vod/nginx.conf.template
@@ -12,10 +12,17 @@ http {     server {         listen 8766; -        # The Kaltura Player page. The default manifest URL changes to the vod manifest.+        # The proof page: the player, the code locations, and a live test.         location = / {             root /app/public;             try_files /index.html =404;+            add_header Cache-Control no-store always;+        }++        # The Kaltura Player page. The default manifest URL changes to the vod manifest.+        location = /player {+            root /app/public;+            try_files /player.html =404;             sub_filter 'value="/media/index.m3u8"' 'value="/hls/${VIDEO_FILE}/index.m3u8"';             sub_filter_once on;             add_header Cache-Control no-store always;@@ -35,6 +42,8 @@ http {             vod_mode local;             vod_segment_duration 4000;             vod_align_segments_to_key_frames on;+            # Relative segment URLs. Behind a TLS proxy, absolute URLs get http:// and the browser blocks them.+            vod_base_url "";              vod_secret_key "${KEY_SECRET}";             vod_hls_encryption_method aes-128;

nginx-vod/Dockerfile

The image contains the sample MP4, the player page as player.html, and this page as index.html. Before, the MP4 came only from a mount.

git diff 5b9124e -- nginx-vod/Dockerfile
@@ -30,10 +30,12 @@ RUN apt-get update \  && rm -rf /var/lib/apt/lists/* COPY --from=build /usr/sbin/nginx /usr/sbin/nginx COPY --from=build /etc/nginx /etc/nginx-COPY public/index.html /app/public/index.html+COPY public/index.html /app/public/player.html+COPY docs/index.html /app/public/index.html COPY nginx-vod/nginx.conf.template nginx-vod/docker-entrypoint.sh /app/+# The sample video. A mount at /media replaces it with your MP4 files.+COPY media/video.mp4 /media/video.mp4 -# Mount the directory with your MP4 files at /media. VOLUME /media ENV KEY_TOKEN=demo-token \     VIDEO_FILE=video.mp4

nginx-vod/docker-compose.yml new file

The deployment platform builds the image from this file. It has one service.

git diff 5b9124e -- nginx-vod/docker-compose.yml
@@ -0,0 +1,13 @@+# One service: nginx with nginx-vod-module serves media/video.mp4 as AES-128 HLS.+name: kaltura-vod++services:+  vod:+    build:+      context: ..+      dockerfile: nginx-vod/Dockerfile+    environment:+      KEY_TOKEN: ${KEY_TOKEN:-demo-token}+      VIDEO_FILE: ${VIDEO_FILE:-video.mp4}+    ports:+      - "${VOD_PORT:-127.0.0.1:8766}:8766"

.dockerignore and .gitignore

Both files ignored all of media/. Now they let media/video.mp4 through, so the sample MP4 goes into the build.

git diff 5b9124e -- .dockerignore
@@ -5,4 +5,5 @@ public/media/ .DS_Store **/.git saas/.env-media/+media/*+!media/video.mp4
git diff 5b9124e -- .gitignore
@@ -11,6 +11,7 @@ vendor/ # SaaS simulation secrets saas/.env -# Your MP4 files for nginx-vod/+# Your MP4 files for nginx-vod/. The image contains media/video.mp4 as the sample. media/* !media/.gitkeep+!media/video.mp4
Production pattern

The same encryption in a Kaltura-style delivery chain

The repository also has a second setup, saas/. It uses the module's own Kaltura configuration templates and two more Kaltura modules. The encryption is the same, and the key protection moves to CDN tokens.

Packager

  • vod_mode mapped: an API returns the MP4 path of each flavor.
  • vod_secret_key "<secret>$vod_filepath": each flavor gets its own key.
  • nginx-secure-token-module adds an Akamai token to the EXT-X-KEY URI only.

API and CDN

  • playManifest checks the KS and the sview privilege, then redirects to the CDN with a token.
  • The CDN checks the token on manifests and keys.
  • Segments have no token, so the CDN caches them for all viewers.
Scope

AES-128 is not DRM

The browser receives the key in clear form, so a skilled user can decrypt the video. The token only controls who gets the key. This page shows the token (demo-token) so that the test works. For strong protection, the same module supports sample-aes (FairPlay) and CENC (Widevine, PlayReady) with a DRM key server through vod_drm_enabled.