stream: encrypt cached stream data (#379)

This commit is contained in:
wukko 2024-03-05 22:59:12 +06:00 committed by GitHub
commit 4d1ac4a00a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 41 deletions

View file

@ -10,7 +10,7 @@ import { apiJSON, checkJSONPost, getIP, languageCode } from "../modules/sub/util
import { Bright, Cyan } from "../modules/sub/consoleText.js"; import { Bright, Cyan } from "../modules/sub/consoleText.js";
import stream from "../modules/stream/stream.js"; import stream from "../modules/stream/stream.js";
import loc from "../localization/manager.js"; import loc from "../localization/manager.js";
import { sha256 } from "../modules/sub/crypto.js"; import { generateHmac } from "../modules/sub/crypto.js";
import { verifyStream } from "../modules/stream/manage.js"; import { verifyStream } from "../modules/stream/manage.js";
export function runAPI(express, app, gitCommit, gitBranch, __dirname) { export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
@ -24,7 +24,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 20, max: 20,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt), keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
return res.status(429).json({ return res.status(429).json({
"status": "rate-limit", "status": "rate-limit",
@ -37,7 +37,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 25, max: 25,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt), keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
return res.status(429).json({ return res.status(429).json({
"status": "rate-limit", "status": "rate-limit",
@ -47,11 +47,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
}); });
const startTime = new Date(); const startTime = new Date();
const startTimestamp = Math.floor(startTime.getTime()); const startTimestamp = startTime.getTime();
app.set('trust proxy', ['loopback', 'uniquelocal']); app.set('trust proxy', ['loopback', 'uniquelocal']);
app.use('/api/:type', cors(corsConfig)); app.use('/api/:type', cors({
methods: ['GET', 'POST'],
...corsConfig
}));
app.use('/api/json', apiLimiter); app.use('/api/json', apiLimiter);
app.use('/api/stream', apiLimiterStream); app.use('/api/stream', apiLimiterStream);
app.use('/api/onDemand', apiLimiter); app.use('/api/onDemand', apiLimiter);
@ -60,6 +64,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') } try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') }
next(); next();
}); });
app.use('/api/json', express.json({ app.use('/api/json', express.json({
verify: (req, res, buf) => { verify: (req, res, buf) => {
let acceptCon = String(req.header('Accept')) === "application/json"; let acceptCon = String(req.header('Accept')) === "application/json";
@ -71,6 +76,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
} }
} }
})); }));
// handle express.json errors properly (https://github.com/expressjs/express/issues/4065) // handle express.json errors properly (https://github.com/expressjs/express/issues/4065)
app.use('/api/json', (err, req, res, next) => { app.use('/api/json', (err, req, res, next) => {
let errorText = "invalid json body"; let errorText = "invalid json body";
@ -86,6 +92,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
next(); next();
} }
}); });
app.post('/api/json', async (req, res) => { app.post('/api/json', async (req, res) => {
try { try {
let lang = languageCode(req); let lang = languageCode(req);
@ -118,13 +125,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try { try {
switch (req.params.type) { switch (req.params.type) {
case 'stream': case 'stream':
if (req.query.t && req.query.h && req.query.e && req.query.t.toString().length === 21 const q = req.query;
&& req.query.h.toString().length === 64 && req.query.e.toString().length === 13) { const checkQueries = q.t && q.e && q.h && q.s && q.i;
let streamInfo = verifyStream(req.query.t, req.query.h, req.query.e); const checkBaseLength = q.t.length === 21 && q.e.length === 13;
const checkSafeLength = q.h.length === 43 && q.s.length === 43 && q.i.length === 22;
if (checkQueries && checkBaseLength && checkSafeLength) {
let streamInfo = verifyStream(q.t, q.h, q.e, q.s, q.i);
if (streamInfo.error) { if (streamInfo.error) {
return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body); return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
} }
if (req.query.p) { if (q.p) {
return res.status(200).json({ return res.status(200).json({
status: "continue" status: "continue"
}); });
@ -132,7 +143,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
return stream(res, streamInfo); return stream(res, streamInfo);
} else { } else {
let j = apiJSON(0, { let j = apiJSON(0, {
t: "stream token, hmac, or expiry timestamp is missing" t: "bad request. stream link may be incomplete or corrupted."
}) })
return res.status(j.status).json(j.body); return res.status(j.status).json(j.body);
} }
@ -159,12 +170,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
}); });
} }
}); });
app.get('/api/status', (req, res) => { app.get('/api/status', (req, res) => {
res.status(200).end() res.status(200).end()
}); });
app.get('/favicon.ico', (req, res) => { app.get('/favicon.ico', (req, res) => {
res.sendFile(`${__dirname}/src/front/icons/favicon.ico`) res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
}); });
app.get('/*', (req, res) => { app.get('/*', (req, res) => {
res.redirect('/api/json') res.redirect('/api/json')
}); });

View file

@ -2,7 +2,7 @@ import NodeCache from "node-cache";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { sha256 } from "../sub/crypto.js"; import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
import { streamLifespan } from "../config.js"; import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({ const streamCache = new NodeCache({
@ -15,48 +15,68 @@ streamCache.on("expired", (key) => {
streamCache.del(key); streamCache.del(key);
}) })
const streamSalt = randomBytes(64).toString('hex'); const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) { export function createStream(obj) {
let streamID = nanoid(), const streamID = nanoid(),
exp = Math.floor(new Date().getTime()) + streamLifespan, iv = randomBytes(16).toString('base64url'),
ghmac = sha256(`${streamID},${obj.service},${exp}`, streamSalt); secret = randomBytes(32).toString('base64url'),
exp = new Date().getTime() + streamLifespan,
if (!streamCache.has(streamID)) { hmac = generateHmac(`${streamID},${exp},${iv},${secret}`, hmacSalt),
streamCache.set(streamID, { streamData = {
id: streamID, exp: exp,
service: obj.service,
type: obj.type, type: obj.type,
urls: obj.u, urls: obj.u,
service: obj.service,
filename: obj.filename, filename: obj.filename,
hmac: ghmac,
exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat, audioFormat: obj.audioFormat,
time: obj.time ? obj.time : false, isAudioOnly: !!obj.isAudioOnly,
copy: !!obj.copy, copy: !!obj.copy,
mute: !!obj.mute, mute: !!obj.mute,
metadata: obj.fileMetadata ? obj.fileMetadata : false metadata: obj.fileMetadata || false
}); };
} else {
let streamInfo = streamCache.get(streamID); streamCache.set(
exp = streamInfo.exp; streamID,
ghmac = streamInfo.hmac; encryptStream(streamData, iv, secret)
)
let streamLink = new URL('/api/stream', process.env.API_URL);
const params = {
't': streamID,
'e': exp,
'h': hmac,
's': secret,
'i': iv
} }
return `${process.env.API_URL}api/stream?t=${streamID}&e=${exp}&h=${ghmac}`;
for (const [key, value] of Object.entries(params)) {
streamLink.searchParams.append(key, value);
}
return streamLink.toString();
} }
export function verifyStream(id, hmac, exp) { export function verifyStream(id, hmac, exp, secret, iv) {
try { try {
let streamInfo = streamCache.get(id.toString()); const ghmac = generateHmac(`${id},${exp},${iv},${secret}`, hmacSalt);
if (ghmac !== String(hmac)) {
return {
error: "i couldn't verify if you have access to this stream. go back and try again!",
status: 401
}
}
const streamInfo = JSON.parse(decryptStream(streamCache.get(id.toString()), iv, secret));
if (!streamInfo) return { if (!streamInfo) return {
error: "this download link has expired or doesn't exist. go back and try again!", error: "this download link has expired or doesn't exist. go back and try again!",
status: 400 status: 400
} }
let ghmac = sha256(`${id},${streamInfo.service},${exp}`, streamSalt); if (String(exp) === String(streamInfo.exp) && Number(exp) > new Date().getTime()) {
if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac)
&& Number(exp) > Math.floor(new Date().getTime())) {
return streamInfo; return streamInfo;
} }
return { return {
@ -64,6 +84,6 @@ export function verifyStream(id, hmac, exp) {
status: 401 status: 401
} }
} catch (e) { } catch (e) {
return { status: 500, body: { status: "error", text: "Internal Server Error" } }; return { status: 500, body: { status: "error", text: "couldn't verify this stream. request a new one!" } };
} }
} }

View file

@ -1,5 +1,23 @@
import { createHmac } from "crypto"; import { createHmac, createCipheriv, createDecipheriv, scryptSync } from "crypto";
export function sha256(str, salt) { const algorithm = "aes256"
return createHmac("sha256", salt).update(str).digest("hex");
export function generateHmac(str, salt) {
return createHmac("sha256", salt).update(str).digest("base64url");
}
export function encryptStream(plaintext, iv, secret) {
const buff = Buffer.from(JSON.stringify(plaintext));
const key = Buffer.from(secret, "base64url");
const cipher = createCipheriv(algorithm, key, Buffer.from(iv, "base64url"));
return Buffer.concat([ cipher.update(buff), cipher.final() ])
}
export function decryptStream(ciphertext, iv, secret) {
const buff = Buffer.from(ciphertext);
const key = Buffer.from(secret, "base64url");
const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, "base64url"));
return Buffer.concat([ decipher.update(buff), decipher.final() ])
} }

View file

@ -11,7 +11,6 @@ const apiVar = {
}, },
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"]
} }
const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '=='];
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
export function apiJSON(type, obj) { export function apiJSON(type, obj) {