diff --git a/assets/js/sabr_helpers.js b/assets/js/sabr_helpers.js index 2481a7ae2..6dcc47471 100644 --- a/assets/js/sabr_helpers.js +++ b/assets/js/sabr_helpers.js @@ -75,6 +75,54 @@ async function encryptRequest(clientKey, data) { return { encrypted: encrypted, hmac: hmac, iv: iv }; } +/** + * Decrypt an (optionally) encrypted Onesie response with AES-CTR and verify + * its HMAC-SHA256. Mirrors googlevideo/examples/onesie-request/utils.ts. + * @param {Uint8Array} iv + * @param {Uint8Array} hmac + * @param {Uint8Array} data + * @param {Uint8Array} clientKeyData - 32-byte client key (16B AES + 16B HMAC) + * @returns {Promise} + */ +async function decryptResponse(iv, hmac, data, clientKeyData) { + if (!iv || !hmac || !data || !clientKeyData) { + throw new Error('Invalid input to decryptResponse'); + } + + var aesKey = await window.crypto.subtle.importKey( + 'raw', + clientKeyData.slice(0, 16), + { name: 'AES-CTR', length: 128 }, + false, + ['decrypt'] + ); + + var decryptedData = new Uint8Array(await window.crypto.subtle.decrypt( + { name: 'AES-CTR', counter: iv, length: 128 }, + aesKey, + data + )); + + var hmacKey = await window.crypto.subtle.importKey( + 'raw', + clientKeyData.slice(16, 32), + { name: 'HMAC', hash: { name: 'SHA-256' } }, + false, + ['verify'] + ); + + var dataToVerify = new Uint8Array(data.length + iv.length); + dataToVerify.set(data, 0); + dataToVerify.set(iv, data.length); + + var isValid = await window.crypto.subtle.verify('HMAC', hmacKey, hmac, dataToVerify); + if (!isValid) { + throw new Error('HMAC verification failed'); + } + + return decryptedData; +} + /** * Check if Onesie client config is still valid * @param {Object} config - Client config object @@ -324,6 +372,7 @@ function generateRandomString(length) { window.SABRHelpers = { getProxyConfig: getProxyConfig, encryptRequest: encryptRequest, + decryptResponse: decryptResponse, isConfigValid: isConfigValid, loadCachedClientConfig: loadCachedClientConfig, saveCachedClientConfig: saveCachedClientConfig, diff --git a/assets/js/sabr_loader.js b/assets/js/sabr_loader.js index ad89a7371..2ff37d257 100644 --- a/assets/js/sabr_loader.js +++ b/assets/js/sabr_loader.js @@ -10,7 +10,7 @@ * onto window.googlevideo so the plain-script plugin can read them at call time. */ -import Innertube, { Platform, Constants } from '/js/sabr/youtubei.js/youtubei.bundle.min.js'; +import Innertube, { Platform, Constants, YT } from '/js/sabr/youtubei.js/youtubei.bundle.min.js'; import { googlevideo } from '/js/sabr/googlevideo/googlevideo.bundle.min.js'; import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js'; @@ -18,6 +18,9 @@ import { BG } from '/js/sabr/bgutils-js/bgutils.bundle.min.js'; window.Innertube = Innertube; window.Platform = Platform; window.Constants = Constants; +// YT.VideoInfo: build a VideoInfo from a raw player response fetched via the +// onesie path (sabr_onesie.js) instead of innertube.getInfo(). +window.YT = YT; // googlevideo namespace (utils, ump, protos) for the SABR scheme plugin + manifest parser window.googlevideo = googlevideo; diff --git a/assets/js/sabr_onesie.js b/assets/js/sabr_onesie.js new file mode 100644 index 000000000..761840c5b --- /dev/null +++ b/assets/js/sabr_onesie.js @@ -0,0 +1,211 @@ +/** + * SABR Onesie - fetch the YouTube player response through an encrypted Onesie + * request (WEB client), instead of a plain /youtubei/v1/player call. + * + * Why: a normal player request is bound to the IP that made it, so the + * server_abr_streaming_url it returns only works from that same IP. An Onesie + * request is proxied by YouTube's "trusted bandaid", so the player response + * (and the SABR streaming URL within it) is not tied to our proxy's egress IP. + * Subsequent media is still pulled with SABR (see sabr_scheme_plugin.js) - this + * module only replaces how the player response itself is obtained. + * + * Adapted from googlevideo/examples/onesie-request/main.ts and the WEB-client + * variant in invidious-secret-companion (override/onesiePlayerReq.ts). + * + * Reads googlevideo protos/ump/utils from window.googlevideo, crypto helpers + * from window.SABRHelpers, and Constants from window.Constants. + * Exposes window.fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig). + */ + +'use strict'; + +(function () { + // Public WEB player API key used for the inner player request. + var PLAYER_API_KEY = 'AIzaSyDCU8hByM-4DrUqRUYnGn-3llEO78bcxq8'; + + function gv() { return window.googlevideo; } + + // Concatenate a UMP part's chunks into a single Uint8Array. + function partBytes(part) { + var chunks = part.data.chunks; + if (chunks.length === 1) return chunks[0]; + return gv().utils.concatenateChunks(chunks); + } + + // Ensure we have a googlevideo redirector base (e.g. https://rrX---snXXX.googlevideo.com). + // Reuses the value preloaded into localStorage by SABRPlayer.initInnertube. + async function getRedirectorBase() { + var stored = null; + try { stored = localStorage.getItem(SABRHelpers.REDIRECTOR_STORAGE_KEY); } catch (e) {} + if (!stored || !stored.startsWith('https://')) { + var resp = await SABRHelpers.fetchWithProxy( + 'https://redirector.googlevideo.com/initplayback?source=youtube&itag=0&pvi=0&pai=0&owc=yes&cmo:sensitive_content=yes&alr=yes&id=' + Math.round(Math.random() * 1e5), + { method: 'GET' } + ); + stored = (await resp.text()).trim(); + if (stored.startsWith('https://')) { + try { localStorage.setItem(SABRHelpers.REDIRECTOR_STORAGE_KEY, stored); } catch (e) {} + } + } + if (!stored.startsWith('https://')) throw new Error('Invalid redirector response'); + return stored.split('/initplayback')[0]; + } + + // Build the encrypted Onesie request body + the hex-encoded video id. + async function prepareOnesieRequest(innertube, videoId, poToken, clientConfig) { + var protos = gv().protos; + var base64ToU8 = gv().utils.base64ToU8; + + // Shallow clone of the session context, deep-copying only `client` since + // that's all we mutate (force WEB client - matches the secret-companion). + var ctx = Object.assign({}, innertube.session.context); + ctx.client = Object.assign({}, innertube.session.context.client, { + clientName: Constants.CLIENTS.WEB.NAME, + clientVersion: Constants.CLIENTS.WEB.VERSION + }); + + var playerRequestJson = { + context: ctx, + playbackContext: { + contentPlaybackContext: { + vis: 0, + splay: false, + lactMilliseconds: '-1', + signatureTimestamp: innertube.session.player && innertube.session.player.signature_timestamp + } + }, + videoId: videoId, + racyCheckOk: true, + contentCheckOk: true + }; + + if (poToken) { + playerRequestJson.serviceIntegrityDimensions = { poToken: poToken }; + } + + var headers = [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'User-Agent', value: ctx.client.userAgent }, + { name: 'X-Goog-Visitor-Id', value: ctx.client.visitorData } + ]; + + var onesieInnertubeRequest = protos.OnesieInnertubeRequest.encode({ + url: 'https://youtubei.googleapis.com/youtubei/v1/player?key=' + PLAYER_API_KEY, + headers: headers, + body: JSON.stringify(playerRequestJson), + proxiedByTrustedBandaid: true, + skipResponseEncryption: true + }).finish(); + + var enc = await SABRHelpers.encryptRequest(clientConfig.clientKeyData, onesieInnertubeRequest); + + var body = protos.OnesieRequest.encode({ + urls: [], + innertubeRequest: { + enableCompression: true, + encryptedClientKey: clientConfig.encryptedClientKey, + encryptedOnesieInnertubeRequest: enc.encrypted, + hmac: enc.hmac, + iv: enc.iv, + useJsonformatterToParsePlayerResponse: false, + serializeResponseAsJson: true + }, + streamerContext: { + sabrContexts: [], + unsentSabrContexts: [], + poToken: poToken ? base64ToU8(poToken) : undefined, + playbackCookie: undefined, + clientInfo: { + clientName: parseInt(Constants.CLIENT_NAME_IDS[Constants.CLIENTS.WEB.NAME], 10), + clientVersion: Constants.CLIENTS.WEB.VERSION + } + }, + bufferedRanges: [], + onesieUstreamerConfig: clientConfig.onesieUstreamerConfig + }).finish(); + + // The `id` query param is the hex of the (base64-decoded) video id. + var videoIdBytes = base64ToU8(videoId); + var hex = []; + for (var i = 0; i < videoIdBytes.length; i++) { + hex.push(videoIdBytes[i].toString(16).padStart(2, '0')); + } + + return { body: body, encodedVideoId: hex.join('') }; + } + + /** + * Fetch and decode the player response JSON via Onesie. + * @returns {Promise} the raw /player response JSON + */ + async function fetchOnesiePlayerResponse(innertube, videoId, poToken, clientConfig) { + if (!clientConfig) throw new Error('Onesie client config not available'); + var protos = gv().protos; + var ump = gv().ump; + + var base = await getRedirectorBase(); + var prepared = await prepareOnesieRequest(innertube, videoId, poToken, clientConfig); + + var url = base + clientConfig.baseUrl + + '&id=' + prepared.encodedVideoId + + '&cmo:sensitive_content=yes' + + '&opr=1' + // Onesie Playback Request + '&osts=0' + // Onesie Start Time Seconds + '&por=1' + + '&rn=0'; + + var resp = await SABRHelpers.fetchWithProxy(url, { + method: 'POST', + headers: { 'accept': '*/*', 'content-type': 'application/octet-stream' }, + body: prepared.body + }); + var buffer = new Uint8Array(await resp.arrayBuffer()); + + // Parse the UMP stream: collect ONESIE_HEADER parts and attach their data. + var onesie = []; + new ump.UmpReader(new ump.CompositeBuffer([buffer])).read(function (part) { + if (part.type === protos.UMPPartId.ONESIE_HEADER) { + onesie.push(protos.OnesieHeader.decode(partBytes(part))); + } else if (part.type === protos.UMPPartId.ONESIE_DATA) { + if (onesie.length) onesie[onesie.length - 1].data = partBytes(part); + } else if (part.type === protos.UMPPartId.SABR_ERROR) { + try { console.error('[SABROnesie] SABR_ERROR', protos.SabrError.decode(partBytes(part))); } catch (e) {} + } + }); + + var header = null; + for (var i = 0; i < onesie.length; i++) { + if (onesie[i].type === protos.OnesieHeaderType.ONESIE_PLAYER_RESPONSE) { header = onesie[i]; break; } + } + if (!header) throw new Error('Onesie player response not found'); + if (!header.cryptoParams) throw new Error('Onesie crypto params not found'); + + var responseData = header.data; + + // Decompress (gzip) if requested. + if (responseData && header.cryptoParams.compressionType === protos.CompressionType.GZIP) { + var ds = new DecompressionStream('gzip'); + var stream = new Blob([responseData]).stream().pipeThrough(ds); + responseData = new Uint8Array(await new Response(stream).arrayBuffer()); + } + + // Decrypt only if the response was encrypted (skipResponseEncryption=true means it usually isn't). + var iv = header.cryptoParams.iv; + var hmac = header.cryptoParams.hmac; + var decrypted = (hmac && hmac.length && iv && iv.length) + ? await SABRHelpers.decryptResponse(iv, hmac, responseData, clientConfig.clientKeyData) + : responseData; + + var innerResponse = protos.OnesieInnertubeResponse.decode(decrypted); + if (innerResponse.onesieProxyStatus !== protos.OnesieProxyStatus.OK) { + throw new Error('Onesie proxy status not OK (' + innerResponse.onesieProxyStatus + ')'); + } + if (innerResponse.httpStatus !== 200) { + throw new Error('Onesie player HTTP status ' + innerResponse.httpStatus); + } + + return JSON.parse(new TextDecoder().decode(innerResponse.body)); + } + + window.fetchOnesiePlayerResponse = fetchOnesiePlayerResponse; +})(); diff --git a/assets/js/sabr_player.js b/assets/js/sabr_player.js index 59876c52a..3d780d291 100644 --- a/assets/js/sabr_player.js +++ b/assets/js/sabr_player.js @@ -504,7 +504,26 @@ var SABRPlayer = (function () { setupRequestFilters(); - var videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined }); + // Fetch the player response through Onesie (WEB client) so the SABR + // streaming URL isn't bound to the proxy's egress IP. Falls back to a + // plain getInfo() if the onesie path is unavailable. Media still flows + // over SABR (sabr_scheme_plugin.js) either way. + var videoInfo; + try { + if (typeof window.fetchOnesiePlayerResponse !== 'function' || !window.YT || !window.YT.VideoInfo) { + throw new Error('Onesie support not loaded'); + } + var playerResponse = await window.fetchOnesiePlayerResponse(innertube, videoId, poToken || undefined, clientConfig); + videoInfo = new window.YT.VideoInfo( + [{ success: true, status_code: 200, data: playerResponse }], + innertube.actions, + SABRHelpers.generateRandomString(16) + ); + console.info('[SABRPlayer]', 'Player response fetched via Onesie (WEB)'); + } catch (onesieErr) { + console.warn('[SABRPlayer]', 'Onesie player request failed, falling back to getInfo:', onesieErr); + videoInfo = await innertube.getInfo(videoId, { po_token: poToken || undefined }); + } if (!videoInfo || !videoInfo.playability_status || videoInfo.playability_status.status !== 'OK') { var reason = (videoInfo && videoInfo.playability_status && videoInfo.playability_status.reason) || 'Unknown error'; throw new Error('Video unavailable: ' + reason); diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index abd2b46e3..87f20ab40 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -125,6 +125,7 @@ +