changeset 49:1bd17c2e5517 default tip

video.py must sign video urls for serve-files.js to serve them
author drewp@bigasterisk.com
date Fri, 06 Dec 2024 17:13:51 -0800
parents 046673b1cc24
children
files Dockerfile deploy.yaml pdm.lock pyproject.toml serve-files.js signature_decode.mjs signature_gen.py src/VideoPage.ts video.py
diffstat 9 files changed, 137 insertions(+), 11 deletions(-) [+]
line wrap: on
line diff
--- a/Dockerfile	Fri Dec 06 14:20:11 2024 -0800
+++ b/Dockerfile	Fri Dec 06 17:13:51 2024 -0800
@@ -10,7 +10,8 @@
 COPY package.json pnpm-lock.yaml ./
 RUN pnpm install
 
-COPY vite.config.ts serve-files.js ./
+COPY vite.config.ts ./
 COPY src/ ./src/
 
+COPY serve-files.js signature_decode.mjs ./
 COPY *.py ./
--- a/deploy.yaml	Fri Dec 06 14:20:11 2024 -0800
+++ b/deploy.yaml	Fri Dec 06 17:13:51 2024 -0800
@@ -18,6 +18,7 @@
       volumes:
         - { name: video-data-download, persistentVolumeClaim: { claimName: video-data-download } }
         - { name: video-data-src, persistentVolumeClaim: { claimName: video-data-src } }
+        - { name: secret, secret: { secretName: video } }
       containers:
         - name: ingest
           image: reg:5000/video_image
@@ -30,6 +31,7 @@
             - { name: video-data-download, mountPath: /data/video-download }
             - { name: video-data-src, mountPath: /data/video-src }
 
+
         - name: files
           image: reg:5000/video_image
           # alternate: [ "webfsd", "-Fp", "9054", "-r", "/vids/" ]
@@ -43,6 +45,7 @@
           volumeMounts:
             - { name: video-data-download, mountPath: /data/video-download }
             - { name: video-data-src, mountPath: /data/video-src }
+            - { name: secret, mountPath: /secret }
 
         - name: api
           image: reg:5000/video_image
@@ -53,6 +56,8 @@
             - video.py
           ports:
             - containerPort: 8004
+          volumeMounts:
+            - { name: secret, mountPath: /secret }
           resources:
             requests:
               cpu: "2"
--- a/pdm.lock	Fri Dec 06 14:20:11 2024 -0800
+++ b/pdm.lock	Fri Dec 06 17:13:51 2024 -0800
@@ -5,7 +5,7 @@
 groups = ["default"]
 strategy = ["cross_platform"]
 lock_version = "4.5.0"
-content_hash = "sha256:f01afb9000019cf18cd9af860dca57705ff881a403601dc2ff246c12bdf8cad8"
+content_hash = "sha256:03a0226774befaaf2f1ae4d0a2a02e8f36036c0f9d68b3e6fae60119777f17ed"
 
 [[metadata.targets]]
 requires_python = ">=3.10"
@@ -335,6 +335,29 @@
 ]
 
 [[package]]
+name = "pycryptodome"
+version = "3.21.0"
+requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+summary = "Cryptographic library for Python"
+files = [
+    {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764"},
+    {file = "pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53"},
+    {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8"},
+    {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6"},
+    {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0"},
+    {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6"},
+    {file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"},
+]
+
+[[package]]
 name = "pycryptodomex"
 version = "3.20.0"
 requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
--- a/pyproject.toml	Fri Dec 06 14:20:11 2024 -0800
+++ b/pyproject.toml	Fri Dec 06 17:13:51 2024 -0800
@@ -19,6 +19,7 @@
     "sse-starlette>=2.1.3",
     "mongo-queue-service>=0.2.1",
     "yt-dlp>=2024.8.6",
+    "pycryptodome>=3.21.0",
 ]
 requires-python = ">=3.10"
 license = { text = "MIT" }
--- a/serve-files.js	Fri Dec 06 14:20:11 2024 -0800
+++ b/serve-files.js	Fri Dec 06 17:13:51 2024 -0800
@@ -1,13 +1,38 @@
 const express = require('express')
-const serveIndex = require('serve-index')
+const decodeSig = async (sig) => {
+    const { decodeSig } = await import('./signature_decode.mjs');
+    return decodeSig(sig);
+};
+const app = express()
 
-const app = express()
+async function checkSig(sig, user, reqPath) {
+    const msg = await decodeSig(sig);
+    if (msg[0] !== user) {
+        throw new Error('user mismatch ' + msg[0] + ' ' + user);
+    }
+    if (msg[1] !== reqPath) {
+        throw new Error('path mismatch');
+    }
+    const now = new Date() / 1000;
+    if (msg[2] < now) {
+        throw new Error('expired');
+    }
+}
+
 
 // e.g. /video/files/video-download/movie1/part1.webm
-
 app.use('/video/files',
+    async (req, res, next) => {
+        try {
+            await checkSig(req.query.sig || '', req.headers['x-pomerium-email'], req.path);
+        } catch (e) {
+            console.error(e);
+            res.status(403).send('403 Forbidden');
+            return;
+        }
+        next();
+    },
     express.static('/data'), // serves file content
-    serveIndex('/data', { 'icons': true }) // serves dir listings
 )
 
-app.listen(8003)
\ No newline at end of file
+app.listen(8003)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/signature_decode.mjs	Fri Dec 06 17:13:51 2024 -0800
@@ -0,0 +1,35 @@
+import { createDecipheriv } from 'crypto';
+import * as fs from 'fs';
+
+const SIGNATURE_KEY = fs.readFileSync('/secret/signature_key');
+
+function removePKCS7Padding(data) {
+    const paddingLength = data.charCodeAt(data.length - 1);
+    return data.slice(0, -paddingLength);
+}
+
+function decryptData(encryptedData, key) {
+    const ivLength = 12; // 12 bytes for GCM
+    const tagLength = 16; // 16 bytes for GCM
+    const decodedData = Buffer.from(encryptedData, 'base64');
+
+    if (decodedData.length < ivLength + tagLength) {
+        throw new Error('Invalid encrypted data length');
+    }
+
+    const iv = decodedData.slice(0, ivLength);
+    const tag = decodedData.slice(-tagLength);
+    const ciphertext = decodedData.slice(ivLength, -tagLength);
+
+    const decipher = createDecipheriv('aes-256-gcm', key, iv);
+    decipher.setAuthTag(tag);
+    let decryptedData = decipher.update(ciphertext, 'base64', 'utf8');
+    decryptedData += decipher.final('utf8');
+    return decryptedData;
+}
+
+export function decodeSig(sig) {
+    const clear = decryptData(sig, SIGNATURE_KEY);
+    const json = removePKCS7Padding(clear.toString('utf-8'));
+    return JSON.parse(json);
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/signature_gen.py	Fri Dec 06 17:13:51 2024 -0800
@@ -0,0 +1,26 @@
+import base64
+import json
+import logging
+from pathlib import Path
+import time
+
+from Crypto.Cipher import AES
+from Crypto.Random import get_random_bytes
+from Crypto.Util.Padding import pad
+
+log = logging.getLogger()
+
+
+SIGNATURE_KEY = Path('/secret/signature_key').read_bytes()
+
+def _encrypt_data(data: bytes, key: bytes) -> str:
+    iv = get_random_bytes(12)
+    cipher = AES.new(key, AES.MODE_GCM, iv)
+    ciphertext, tag = cipher.encrypt_and_digest(pad(data, AES.block_size))
+    return base64.b64encode(iv + ciphertext + tag).decode('utf-8')
+
+
+def makePlaybackSig(user: str, webDataPath: str, lifeSeconds=3600) -> str:
+    msg = [user, webDataPath, int(time.time() + lifeSeconds)]
+    return _encrypt_data(
+        json.dumps(msg).encode('utf-8'), SIGNATURE_KEY)
--- a/src/VideoPage.ts	Fri Dec 06 14:20:11 2024 -0800
+++ b/src/VideoPage.ts	Fri Dec 06 17:13:51 2024 -0800
@@ -14,6 +14,10 @@
   label: string;
 }
 
+interface AutoplayVideoFile extends VideoFile {
+  sig: string;
+}
+
 interface Subdir {
   label: string;
   path: string;
@@ -24,7 +28,7 @@
   dirLabel: string;
   videos: VideoFile[];
   subdirs: Subdir[];
-  autoplay: VideoFile | null;
+  autoplay: AutoplayVideoFile | null;
 }
 
 function subdirQuery(subdir: string): string {
@@ -178,9 +182,9 @@
     return this.shadowRoot!.querySelector("#scrim")!;
   }
 
-  async startPlayer(p: VideoFile) {
+  async startPlayer(p: AutoplayVideoFile) {
     const player = await this.pagePlayer;
-    player.manifest = this.escapeALittle(PATH_PREFIX + "/files" + p.webDataPath);
+    player.manifest = PATH_PREFIX + "/files" + this.escapeALittle(p.webDataPath) + "?sig=" + encodeURIComponent(p.sig);
     const sv = player.shadowRoot!.querySelector("shaka-video")! as ShakaVideoElement;
 
     sv.src = player.manifest;
--- a/video.py	Fri Dec 06 14:20:11 2024 -0800
+++ b/video.py	Fri Dec 06 17:13:51 2024 -0800
@@ -15,6 +15,7 @@
 from starlette_exporter import PrometheusMiddleware, handle_metrics
 
 import dl_queue
+from signature_gen import makePlaybackSig
 import thumbnail
 from mongo_required import open_mongo_or_die
 from video_file_store import VideoFileStore
@@ -33,6 +34,10 @@
 async def videos(req: Request) -> JSONResponse:
     # either like /dir1/dir2/ or /dir1/dir2/vid1
     subdir = req.query_params.get('subdir', '/')
+    user = req.headers.get('x-pomerium-email', '')
+    if not user:
+        raise ValueError('must be logged in')
+    log.info(f'videos for {user!r}')
 
     subdir = unquote(subdir)
     webDirRelPath = subdir.rsplit('/', 1)[0] + '/'
@@ -63,7 +68,8 @@
                 resp['autoplay'] = {
                     'webRelPath': '/' + vf.webRelPath,
                     'webDataPath': '/' + vf.webDataPath,
-                    'label': vf.label
+                    'label': vf.label,
+                    'sig': makePlaybackSig(user, '/' + vf.webDataPath),
                 }
                 break
         else: