Make JavaScript code compatible with older WebViews

This commit is contained in:
Stypox 2025-02-04 21:35:55 +01:00
parent 21df24abfd
commit 53b599b042
No known key found for this signature in database
GPG key ID: 4BDF1B40A49FDD23
4 changed files with 271 additions and 224 deletions

View file

@ -1,36 +1,28 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
class BotGuardClient {
constructor(options) {
this.userInteractionElement = options.userInteractionElement;
this.vm = options.globalObj[options.globalName];
this.program = options.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
}
/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
static async create(options) {
return await new BotGuardClient(options).load();
}
function loadBotGuard(challengeData) {
this.vm = this[challengeData.globalName];
this.program = challengeData.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
async load() {
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');
if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');
const vmFunctionsCallback = (
const vmFunctionsCallback = function (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) => {
) {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
@ -39,22 +31,27 @@ class BotGuardClient {
};
};
try {
this.syncSnapshotFunction = await this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, () => {/** no-op */ }, [ [], [] ])[0];
} catch (error) {
throw new Error(`[BotGuardClient]: Failed to load program (${error.message})`);
}
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
for (let i = 0; i < 10000 && !this.vmFunctions.asyncSnapshotFunction; ++i) {
await new Promise(f => setTimeout(f, 1))
return new Promise(function (resolve, reject) {
i = 0
refreshIntervalId = setInterval(function () {
if (!!this.vmFunctions.asyncSnapshotFunction) {
resolve(this)
clearInterval(refreshIntervalId);
}
return this;
if (i >= 10000) {
reject("asyncSnapshotFunction is null even after 10 seconds")
clearInterval(refreshIntervalId);
}
i += 1;
}, 1);
})
}
/**
@ -73,12 +70,12 @@ class BotGuardClient {
* console.log(result);
* ```
*/
async snapshot(args) {
return new Promise((resolve, reject) => {
function snapshot(args) {
return new Promise(function (resolve, reject) {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
this.vmFunctions.asyncSnapshotFunction((response) => resolve(response), [
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
@ -86,119 +83,38 @@ class BotGuardClient {
]);
});
}
}
/**
* Parses the challenge data from the provided response data.
*/
function parseChallengeData(rawData) {
let challengeData = [];
if (rawData.length > 1 && typeof rawData[1] === 'string') {
const descrambled = descramble(rawData[1]);
challengeData = JSON.parse(descrambled || '[]');
} else if (rawData.length && typeof rawData[0] === 'object') {
challengeData = rawData[0];
}
const [ messageId, wrappedScript, wrappedUrl, interpreterHash, program, globalName, , clientExperimentsStateBlob ] = challengeData;
const privateDoNotAccessOrElseSafeScriptWrappedValue = Array.isArray(wrappedScript) ? wrappedScript.find((value) => value && typeof value === 'string') : null;
const privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = Array.isArray(wrappedUrl) ? wrappedUrl.find((value) => value && typeof value === 'string') : null;
return {
messageId,
interpreterJavascript: {
privateDoNotAccessOrElseSafeScriptWrappedValue,
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
},
interpreterHash,
program,
globalName,
clientExperimentsStateBlob
};
}
/**
* Descrambles the given challenge data.
*/
function descramble(scrambledChallenge) {
const buffer = base64ToU8(scrambledChallenge);
if (buffer.length)
return new TextDecoder().decode(buffer.map((b) => b + 97));
}
const base64urlCharRegex = /[-_.]/g;
const base64urlToBase64Map = {
'-': '+',
_: '/',
'.': '='
};
function base64ToU8(base64) {
let base64Mod;
if (base64urlCharRegex.test(base64)) {
base64Mod = base64.replace(base64urlCharRegex, function (match) {
return base64urlToBase64Map[match];
});
} else {
base64Mod = base64;
}
base64Mod = atob(base64Mod);
return new Uint8Array(
[ ...base64Mod ].map(
(char) => char.charCodeAt(0)
)
);
}
function u8ToBase64(u8, base64url = false) {
const result = btoa(String.fromCharCode(...u8));
if (base64url) {
return result
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
return result;
}
async function runBotGuard(rawChallengeData) {
const challengeData = parseChallengeData(rawChallengeData)
function runBotGuard(challengeData) {
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
const botguard = await BotGuardClient.create({
const webPoSignalOutput = [];
return loadBotGuard({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
});
const webPoSignalOutput = [];
const botguardResponse = await botguard.snapshot({ webPoSignalOutput });
return { webPoSignalOutput, botguardResponse }
}).then(function (botguard) {
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
}).then(function (botguardResponse) {
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
})
}
async function obtainPoToken(webPoSignalOutput, integrityTokenResponse, identifier) {
const integrityToken = integrityTokenResponse[0];
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
const getMinter = webPoSignalOutput[0];
if (!getMinter)
throw new Error('PMD:Undefined');
const mintCallback = await getMinter(base64ToU8(integrityToken));
const mintCallback = getMinter(integrityToken);
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
const result = await mintCallback(new TextEncoder().encode(identifier));
const result = mintCallback(identifier);
if (!result)
throw new Error('YNJ:Undefined');
@ -206,6 +122,6 @@ async function obtainPoToken(webPoSignalOutput, integrityTokenResponse, identifi
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');
return u8ToBase64(result, true);
return result;
}
</script></head><body></body></html>

View file

@ -0,0 +1,113 @@
package org.schabi.newpipe.util.potoken
import com.grack.nanojson.JsonObject
import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonWriter
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
/**
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
* embedded in a JavaScript snippet.
*/
fun parseChallengeData(rawChallengeData: String): String {
val scrambled = JsonParser.array().from(rawChallengeData)
val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) {
val descrambled = descramble(scrambled.getString(1))
JsonParser.array().from(descrambled)
} else {
scrambled.getArray(1)
}
val messageId = challengeData.getString(0)
val interpreterHash = challengeData.getString(3)
val program = challengeData.getString(4)
val globalName = challengeData.getString(5)
val clientExperimentsStateBlob = challengeData.getString(7)
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String }
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String }
return JsonWriter.string(
JsonObject.builder()
.value("messageId", messageId)
.`object`("interpreterJavascript")
.value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue)
.value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)
.end()
.value("interpreterHash", interpreterHash)
.value("program", program)
.value("globalName", globalName)
.value("clientExperimentsStateBlob", clientExperimentsStateBlob)
.done()
)
}
/**
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
* duration of this token in seconds.
*/
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData)
return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1)
}
/**
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code.
*/
fun stringToU8(identifier: String): String {
return newUint8Array(identifier.toByteArray())
}
/**
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
* and converts it to the specific base64 representation for poTokens.
*/
fun u8ToBase64(poToken: String): String {
return poToken.split(",")
.map { it.toUByte().toByte() }
.toByteArray()
.toByteString()
.base64()
.replace("+", "-")
.replace("/", "_")
}
/**
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
*/
private fun descramble(scrambledChallenge: String): String {
return base64ToByteString(scrambledChallenge)
.map { (it + 97).toByte() }
.toByteArray()
.decodeToString()
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
*/
private fun base64ToU8(base64: String): String {
return newUint8Array(base64ToByteString(base64))
}
private fun newUint8Array(contents: ByteArray): String {
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
*/
private fun base64ToByteString(base64: String): ByteArray {
val base64Mod = base64
.replace('-', '+')
.replace('_', '/')
.replace('.', '=')
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
.toByteArray()
}

View file

@ -58,12 +58,6 @@ object PoTokenProviderImpl : PoTokenProvider {
webPoTokenGenerator!!.isExpired()
if (shouldRecreate) {
// close the current webPoTokenGenerator on the main thread
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView
.newPoTokenGenerator(App.getApp()).blockingGet()
val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient()
innertubeClientRequestInfo.clientInfo.clientVersion =
@ -78,6 +72,12 @@ object PoTokenProviderImpl : PoTokenProvider {
null,
false
)
// close the current webPoTokenGenerator on the main thread
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView
.newPoTokenGenerator(App.getApp()).blockingGet()
// The streaming poToken needs to be generated exactly once before generating
// any other (player) tokens.

View file

@ -52,10 +52,11 @@ class PoTokenWebView private constructor(
// supports a really old version of JS.
val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})"
val exception = BadWebViewException(fmt)
Log.e(TAG, "This WebView implementation is broken: $fmt")
// This can only happen during initialization, where there is no try-catch
onInitializationErrorCloseAndCancel(BadWebViewException(fmt))
onInitializationErrorCloseAndCancel(exception)
popAllPoTokenEmitters().forEach { (_, emitter) -> emitter.onError(exception) }
}
return super.onConsoleMessage(m)
}
@ -84,7 +85,7 @@ class PoTokenWebView private constructor(
{ html ->
webView.loadDataWithBaseURL(
"https://www.youtube.com",
html.replace(
html.replaceFirst(
"</script>",
// calls downloadAndRunBotguard() when the page has finished loading
"\n$JS_INTERFACE.downloadAndRunBotguard()</script>"
@ -113,18 +114,21 @@ class PoTokenWebView private constructor(
"https://www.youtube.com/api/jnn/v1/Create",
"[ \"$REQUEST_KEY\" ]",
) { responseBody ->
val parsedChallengeData = parseChallengeData(responseBody)
webView.evaluateJavascript(
"""(async function() {
try {
data = JSON.parse(String.raw`$responseBody`)
result = await runBotGuard(data)
"""try {
data = $parsedChallengeData
runBotGuard(data).then(function (result) {
this.webPoSignalOutput = result.webPoSignalOutput
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
}, function (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
})
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error.toString())
}
})();""",
) {}
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
}""",
null
)
}
}
@ -156,38 +160,24 @@ class PoTokenWebView private constructor(
if (BuildConfig.DEBUG) {
Log.d(TAG, "GenerateIT response: $responseBody")
}
webView.evaluateJavascript(
"""(async function() {
try {
this.integrityToken = JSON.parse(String.raw`$responseBody`)
$JS_INTERFACE.onInitializationFinished(integrityToken[1])
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error.toString())
}
})();""",
) {}
}
}
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
/**
* Called during initialization by the JavaScript snippet from [onRunBotguardResult] when the
* `integrityToken` has been received by JavaScript.
*
* @param expirationTimeInSeconds in how many seconds the integrity token expires, can be found
* in `integrityToken[1]`
*/
@JavascriptInterface
fun onInitializationFinished(expirationTimeInSeconds: Long) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "onInitializationFinished() called, expiration=${expirationTimeInSeconds}s")
}
// leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600)
webView.evaluateJavascript(
"this.integrityToken = $integrityToken"
) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
}
generatorEmitter.onSuccess(this)
}
}
}
//endregion
//region Obtaining poTokens
//region Handling multiple emitters
/**
* Adds the ([identifier], [emitter]) pair to the [poTokenEmitters] list. This makes it so that
* multiple poToken requests can be generated invparallel, and the results will be notified to
@ -212,6 +202,20 @@ class PoTokenWebView private constructor(
}
}
/**
* Clears [poTokenEmitters] and returns its previous contents. The emitters are supposed to be
* used immediately after to either signal a success or an error.
*/
private fun popAllPoTokenEmitters(): List<Pair<String, SingleEmitter<String>>> {
return synchronized(poTokenEmitters) {
val result = poTokenEmitters.toList()
poTokenEmitters.clear()
result
}
}
//endregion
//region Obtaining poTokens
override fun generatePoToken(identifier: String): Single<String> =
Single.create { emitter ->
if (BuildConfig.DEBUG) {
@ -219,17 +223,21 @@ class PoTokenWebView private constructor(
}
runOnMainThread(emitter) {
addPoTokenEmitter(identifier, emitter)
val u8Identifier = stringToU8(identifier)
webView.evaluateJavascript(
"""(async function() {
identifier = String.raw`$identifier`
try {
poToken = await obtainPoToken(webPoSignalOutput, integrityToken,
identifier)
$JS_INTERFACE.onObtainPoTokenResult(identifier, poToken)
} catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error.toString())
"""try {
identifier = "$identifier"
u8Identifier = $u8Identifier
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
poTokenU8String = ""
for (i = 0; i < poTokenU8.length; i++) {
if (i != 0) poTokenU8String += ","
poTokenU8String += poTokenU8[i]
}
})();""",
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
} catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
}""",
) {}
}
}
@ -251,7 +259,17 @@ class PoTokenWebView private constructor(
* result of the JavaScript `obtainPoToken()` function.
*/
@JavascriptInterface
fun onObtainPoTokenResult(identifier: String, poToken: String) {
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
}
val poToken = try {
u8ToBase64(poTokenU8)
} catch (t: Throwable) {
popPoTokenEmitter(identifier)?.onError(t)
return
}
if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
}