Files
qapp-core/public/sw-video-decrypt.js

188 lines
5.6 KiB
JavaScript

// Service Worker for decrypting encrypted videos on-the-fly
// This allows streaming large encrypted videos without loading everything into memory
// Cache for storing encryption configurations
const ENCRYPTION_CACHE = new Map();
// Listen for messages from the main thread
self.addEventListener('message', (event) => {
if (event.data.type === 'SET_ENCRYPTION') {
const videoId = event.data.videoId;
const config = {
key: new Uint8Array(event.data.key),
iv: new Uint8Array(event.data.iv),
resourceUrl: event.data.resourceUrl,
totalSize: event.data.totalSize,
mimeType: event.data.mimeType || 'video/mp4',
};
ENCRYPTION_CACHE.set(videoId, config);
// Respond back to confirm
event.ports[0].postMessage({ success: true });
} else if (event.data.type === 'REMOVE_ENCRYPTION') {
const videoId = event.data.videoId;
ENCRYPTION_CACHE.delete(videoId);
event.ports[0].postMessage({ success: true });
}
});
// Intercept fetch requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Check if this is a request for an encrypted video
if (url.pathname.startsWith('/decrypt-video/')) {
const videoId = url.pathname.split('/')[2];
event.respondWith(handleEncryptedVideo(event.request, videoId));
}
});
// Handle encrypted video requests
async function handleEncryptedVideo(request, videoId) {
try {
const config = ENCRYPTION_CACHE.get(videoId);
if (!config) {
console.error('[SW] Video config not found for:', videoId);
return new Response('Video configuration not found', { status: 404 });
}
const rangeHeader = request.headers.get('range');
// Handle HEAD request or requests without range
if (!rangeHeader) {
return new Response(null, {
status: 200,
headers: {
'Content-Length': String(config.totalSize),
'Content-Type': config.mimeType || 'video/mp4',
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
},
});
}
// Parse range header: "bytes=start-end"
const rangeMatch = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!rangeMatch) {
return new Response('Invalid range header', { status: 416 });
}
const start = parseInt(rangeMatch[1], 10);
const end = rangeMatch[2]
? parseInt(rangeMatch[2], 10)
: Math.min(start + 5 * 1024 * 1024 - 1, config.totalSize - 1); // 5MB chunks
// Fetch encrypted data from the actual resource
const encryptedResponse = await fetch(config.resourceUrl, {
headers: {
Range: `bytes=${start}-${end}`,
},
});
if (!encryptedResponse.ok && encryptedResponse.status !== 206) {
console.error(
'[SW] Failed to fetch encrypted data:',
encryptedResponse.status
);
return new Response('Failed to fetch encrypted data', {
status: encryptedResponse.status,
});
}
const encrypted = new Uint8Array(await encryptedResponse.arrayBuffer());
// Decrypt the chunk
const blockOffset = BigInt(start >> 4);
const decrypted = await decryptAesCtrChunk(
config.key,
config.iv,
blockOffset,
encrypted
);
// Calculate actual end based on what we got
const actualEnd = start + decrypted.length - 1;
// Return decrypted data as partial content
return new Response(decrypted, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${actualEnd}/${config.totalSize}`,
'Content-Length': String(decrypted.length),
'Content-Type': config.mimeType || 'video/mp4',
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
},
});
} catch (error) {
console.error('[SW] Error handling encrypted video:', error);
return new Response(`Error: ${error.message}`, { status: 500 });
}
}
// AES-CTR decryption utilities
function deriveCtrCounter(iv, blockOffset) {
const counter = new Uint8Array(16);
const ivArray = new Uint8Array(iv);
counter.set(ivArray);
let carry = blockOffset;
for (let i = 15; i >= 0 && carry > 0n; i--) {
const sum = BigInt(counter[i]) + (carry & 0xffn);
counter[i] = Number(sum & 0xffn);
carry = (carry >> 8n) + (sum >> 8n);
}
return counter;
}
async function decryptAesCtrChunk(keyBytes, ivBytes, blockOffset, ciphertext) {
// Try WebCrypto API first (faster)
if (self.crypto?.subtle) {
try {
const keyBuffer = new Uint8Array(keyBytes).buffer;
const cryptoKey = await self.crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'AES-CTR' },
false,
['decrypt']
);
const counter = deriveCtrCounter(ivBytes, blockOffset);
const counterArray = new Uint8Array(counter);
const ciphertextBuffer = new Uint8Array(ciphertext).buffer;
const decrypted = await self.crypto.subtle.decrypt(
{
name: 'AES-CTR',
counter: counterArray,
length: 128,
},
cryptoKey,
ciphertextBuffer
);
return new Uint8Array(decrypted);
} catch (e) {
console.warn('[SW] WebCrypto decrypt failed, using fallback:', e);
}
}
// Fallback to aes-js (if available)
// Note: In production, you might want to import aes-js into the service worker
// For now, we'll rely on WebCrypto which should work in all modern browsers
throw new Error('WebCrypto not available and no fallback configured');
}
// Service Worker installation
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});