mirror of
https://github.com/wukko/cobalt.git
synced 2024-11-17 22:00:00 +00:00
stream: encrypt cached stream data (#379)
This commit is contained in:
commit
4d1ac4a00a
4 changed files with 92 additions and 41 deletions
|
@ -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')
|
||||||
});
|
});
|
||||||
|
|
|
@ -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!" } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() ])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in a new issue