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 stream from "../modules/stream/stream.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";
export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
@ -24,7 +24,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 20,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt),
keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => {
return res.status(429).json({
"status": "rate-limit",
@ -37,7 +37,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 25,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt),
keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => {
return res.status(429).json({
"status": "rate-limit",
@ -47,11 +47,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
});
const startTime = new Date();
const startTimestamp = Math.floor(startTime.getTime());
const startTimestamp = startTime.getTime();
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/stream', apiLimiterStream);
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('/') }
next();
});
app.use('/api/json', express.json({
verify: (req, res, buf) => {
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)
app.use('/api/json', (err, req, res, next) => {
let errorText = "invalid json body";
@ -86,6 +92,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
next();
}
});
app.post('/api/json', async (req, res) => {
try {
let lang = languageCode(req);
@ -118,13 +125,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try {
switch (req.params.type) {
case 'stream':
if (req.query.t && req.query.h && req.query.e && req.query.t.toString().length === 21
&& req.query.h.toString().length === 64 && req.query.e.toString().length === 13) {
let streamInfo = verifyStream(req.query.t, req.query.h, req.query.e);
const q = req.query;
const checkQueries = q.t && q.e && q.h && q.s && q.i;
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) {
return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
}
if (req.query.p) {
if (q.p) {
return res.status(200).json({
status: "continue"
});
@ -132,7 +143,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
return stream(res, streamInfo);
} else {
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);
}
@ -159,12 +170,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
});
}
});
app.get('/api/status', (req, res) => {
res.status(200).end()
});
app.get('/favicon.ico', (req, res) => {
res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
});
app.get('/*', (req, res) => {
res.redirect('/api/json')
});

View file

@ -2,7 +2,7 @@ import NodeCache from "node-cache";
import { randomBytes } from "crypto";
import { nanoid } from 'nanoid';
import { sha256 } from "../sub/crypto.js";
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({
@ -15,48 +15,68 @@ streamCache.on("expired", (key) => {
streamCache.del(key);
})
const streamSalt = randomBytes(64).toString('hex');
const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) {
let streamID = nanoid(),
exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = sha256(`${streamID},${obj.service},${exp}`, streamSalt);
if (!streamCache.has(streamID)) {
streamCache.set(streamID, {
id: streamID,
service: obj.service,
const streamID = nanoid(),
iv = randomBytes(16).toString('base64url'),
secret = randomBytes(32).toString('base64url'),
exp = new Date().getTime() + streamLifespan,
hmac = generateHmac(`${streamID},${exp},${iv},${secret}`, hmacSalt),
streamData = {
exp: exp,
type: obj.type,
urls: obj.u,
service: obj.service,
filename: obj.filename,
hmac: ghmac,
exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat,
time: obj.time ? obj.time : false,
isAudioOnly: !!obj.isAudioOnly,
copy: !!obj.copy,
mute: !!obj.mute,
metadata: obj.fileMetadata ? obj.fileMetadata : false
});
} else {
let streamInfo = streamCache.get(streamID);
exp = streamInfo.exp;
ghmac = streamInfo.hmac;
metadata: obj.fileMetadata || false
};
streamCache.set(
streamID,
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 {
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 {
error: "this download link has expired or doesn't exist. go back and try again!",
status: 400
}
let ghmac = sha256(`${id},${streamInfo.service},${exp}`, streamSalt);
if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac)
&& Number(exp) > Math.floor(new Date().getTime())) {
if (String(exp) === String(streamInfo.exp) && Number(exp) > new Date().getTime()) {
return streamInfo;
}
return {
@ -64,6 +84,6 @@ export function verifyStream(id, hmac, exp) {
status: 401
}
} 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) {
return createHmac("sha256", salt).update(str).digest("hex");
const algorithm = "aes256"
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"]
}
const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '=='];
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
export function apiJSON(type, obj) {