diff --git a/src/core/api.js b/src/core/api.js index 8b35963..eda3c01 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -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') }); diff --git a/src/modules/stream/manage.js b/src/modules/stream/manage.js index 92a98da..2e4cb0f 100644 --- a/src/modules/stream/manage.js +++ b/src/modules/stream/manage.js @@ -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!" } }; } } diff --git a/src/modules/sub/crypto.js b/src/modules/sub/crypto.js index e8bf2f9..b3a0539 100644 --- a/src/modules/sub/crypto.js +++ b/src/modules/sub/crypto.js @@ -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() ]) } diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 8f8678d..bb21bbb 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -11,7 +11,6 @@ const apiVar = { }, booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] } -const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '==']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; export function apiJSON(type, obj) {