Skip to content

Commit 2229c6e

Browse files
committed
feat(StreamsExtractor): generate PoToken
Implements support for locally generating PoTokens using the device webview. This is a direct port of TeamNewPipe/NewPipe#11955 to native Kotlin. Closes: libre-tube#7065
1 parent 470c3bb commit 2229c6e

16 files changed

+696
-11
lines changed

app/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ dependencies {
133133
/* NewPipe Extractor */
134134
implementation(libs.newpipeextractor)
135135

136+
136137
/* Coil */
137138
coreLibraryDesugaring(libs.desugaring)
138139
implementation(libs.coil)

app/src/main/assets/po_token.html

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<!DOCTYPE html>
2+
<html lang="en"><head><title></title><script>
3+
/**
4+
* Factory method to create and load a BotGuardClient instance.
5+
* @param options - Configuration options for the BotGuardClient.
6+
* @returns A promise that resolves to a loaded BotGuardClient instance.
7+
*/
8+
function loadBotGuard(challengeData) {
9+
this.vm = this[challengeData.globalName];
10+
this.program = challengeData.program;
11+
this.vmFunctions = {};
12+
this.syncSnapshotFunction = null;
13+
14+
if (!this.vm)
15+
throw new Error('[BotGuardClient]: VM not found in the global object');
16+
17+
if (!this.vm.a)
18+
throw new Error('[BotGuardClient]: Could not load program');
19+
20+
const vmFunctionsCallback = function (
21+
asyncSnapshotFunction,
22+
shutdownFunction,
23+
passEventFunction,
24+
checkCameraFunction
25+
) {
26+
this.vmFunctions = {
27+
asyncSnapshotFunction: asyncSnapshotFunction,
28+
shutdownFunction: shutdownFunction,
29+
passEventFunction: passEventFunction,
30+
checkCameraFunction: checkCameraFunction
31+
};
32+
};
33+
34+
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
35+
36+
// an asynchronous function runs in the background and it will eventually call
37+
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
38+
// control to the things running in the background by interrupting this async
39+
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
40+
// needed but is there just because.
41+
return new Promise(function (resolve, reject) {
42+
i = 0
43+
refreshIntervalId = setInterval(function () {
44+
if (!!this.vmFunctions.asyncSnapshotFunction) {
45+
resolve(this)
46+
clearInterval(refreshIntervalId);
47+
}
48+
if (i >= 10000) {
49+
reject("asyncSnapshotFunction is null even after 10 seconds")
50+
clearInterval(refreshIntervalId);
51+
}
52+
i += 1;
53+
}, 1);
54+
})
55+
}
56+
57+
/**
58+
* Takes a snapshot asynchronously.
59+
* @returns The snapshot result.
60+
* @example
61+
* ```ts
62+
* const result = await botguard.snapshot({
63+
* contentBinding: {
64+
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
65+
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
66+
* encryptedVideoId: "P-vC09ZJcnM"
67+
* }
68+
* });
69+
*
70+
* console.log(result);
71+
* ```
72+
*/
73+
function snapshot(args) {
74+
return new Promise(function (resolve, reject) {
75+
if (!this.vmFunctions.asyncSnapshotFunction)
76+
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
77+
78+
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
79+
args.contentBinding,
80+
args.signedTimestamp,
81+
args.webPoSignalOutput,
82+
args.skipPrivacyBuffer
83+
]);
84+
});
85+
}
86+
87+
function runBotGuard(challengeData) {
88+
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
89+
90+
if (interpreterJavascript) {
91+
new Function(interpreterJavascript)();
92+
} else throw new Error('Could not load VM');
93+
94+
const webPoSignalOutput = [];
95+
return loadBotGuard({
96+
globalName: challengeData.globalName,
97+
globalObj: this,
98+
program: challengeData.program
99+
}).then(function (botguard) {
100+
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
101+
}).then(function (botguardResponse) {
102+
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
103+
})
104+
}
105+
106+
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
107+
const getMinter = webPoSignalOutput[0];
108+
109+
if (!getMinter)
110+
throw new Error('PMD:Undefined');
111+
112+
const mintCallback = getMinter(integrityToken);
113+
114+
if (!(mintCallback instanceof Function))
115+
throw new Error('APF:Failed');
116+
117+
const result = mintCallback(identifier);
118+
119+
if (!result)
120+
throw new Error('YNJ:Undefined');
121+
122+
if (!(result instanceof Uint8Array))
123+
throw new Error('ODM:Invalid');
124+
125+
return result;
126+
}
127+
</script></head><body></body></html>

app/src/main/java/com/github/libretube/api/ExternalApi.kt

+17
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@ import com.github.libretube.api.obj.PipedInstance
55
import com.github.libretube.api.obj.SubmitSegmentResponse
66
import com.github.libretube.api.obj.VoteInfo
77
import com.github.libretube.obj.update.UpdateInfo
8+
import kotlinx.serialization.json.JsonElement
89
import retrofit2.http.Body
910
import retrofit2.http.GET
11+
import retrofit2.http.Headers
1012
import retrofit2.http.POST
1113
import retrofit2.http.Query
1214
import retrofit2.http.Url
1315

1416
private const val GITHUB_API_URL = "https://api.github.com/repos/libre-tube/LibreTube/releases/latest"
1517
private const val SB_API_URL = "https://sponsor.ajay.app"
1618
private const val RYD_API_URL = "https://returnyoutubedislikeapi.com"
19+
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw"
20+
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3"
1721

1822
interface ExternalApi {
1923
// only for fetching servers list
@@ -51,4 +55,17 @@ interface ExternalApi {
5155
@Query("userID") userID: String,
5256
@Query("type") score: Int
5357
)
58+
59+
@Headers(
60+
"User-Agent: $USER_AGENT",
61+
"Accept: application/json",
62+
"Content-Type: application/json+protobuf",
63+
"x-goog-api-key: $GOOGLE_API_KEY",
64+
"x-user-agent: grpc-web-javascript/0.1",
65+
)
66+
@POST
67+
suspend fun botguardRequest(
68+
@Url url: String,
69+
@Body jsonPayload: List<String>
70+
): JsonElement
5471
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.github.libretube.api.local
2+
3+
import okio.ByteString.Companion.decodeBase64
4+
import okio.ByteString.Companion.toByteString
5+
6+
import kotlinx.serialization.json.Json
7+
import kotlinx.serialization.json.JsonNull
8+
import kotlinx.serialization.json.JsonObject
9+
import kotlinx.serialization.json.JsonPrimitive
10+
import kotlinx.serialization.json.jsonArray
11+
import kotlinx.serialization.json.jsonPrimitive
12+
import kotlinx.serialization.json.long
13+
14+
/**
15+
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
16+
* embedded in a JavaScript snippet.
17+
*/
18+
fun parseChallengeData(rawChallengeData: String): String {
19+
val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray
20+
21+
val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) {
22+
val descrambled = descramble(scrambled[1].jsonPrimitive.content)
23+
Json.parseToJsonElement(descrambled).jsonArray
24+
} else {
25+
scrambled[1].jsonArray
26+
}
27+
28+
val messageId = challengeData[0].jsonPrimitive.content
29+
val interpreterHash = challengeData[3].jsonPrimitive.content
30+
val program = challengeData[4].jsonPrimitive.content
31+
val globalName = challengeData[5].jsonPrimitive.content
32+
val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content
33+
34+
35+
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1]
36+
.takeIf { it !is JsonNull }
37+
?.jsonArray
38+
?.find { it.jsonPrimitive.isString }
39+
40+
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2]
41+
.takeIf { it !is JsonNull }
42+
?.jsonArray
43+
?.find { it.jsonPrimitive.isString }
44+
45+
46+
return Json.encodeToString(
47+
JsonObject.serializer(), JsonObject(
48+
mapOf(
49+
"messageId" to JsonPrimitive(messageId),
50+
"interpreterJavascript" to JsonObject(
51+
mapOf(
52+
"privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue
53+
?: JsonPrimitive("")),
54+
"privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
55+
?: JsonPrimitive(""))
56+
)
57+
),
58+
"interpreterHash" to JsonPrimitive(interpreterHash),
59+
"program" to JsonPrimitive(program),
60+
"globalName" to JsonPrimitive(globalName),
61+
"clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob)
62+
)
63+
)
64+
)
65+
}
66+
67+
/**
68+
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
69+
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
70+
* duration of this token in seconds.
71+
*/
72+
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
73+
val integrityTokenData = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray
74+
return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long
75+
}
76+
77+
/**
78+
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
79+
* `Uint8Array` that can be embedded directly in JavaScript code.
80+
*/
81+
fun stringToU8(identifier: String): String {
82+
return newUint8Array(identifier.toByteArray())
83+
}
84+
85+
/**
86+
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
87+
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
88+
* and converts it to the specific base64 representation for poTokens.
89+
*/
90+
fun u8ToBase64(poToken: String): String {
91+
return poToken.split(",")
92+
.map { it.toUByte().toByte() }
93+
.toByteArray()
94+
.toByteString()
95+
.base64()
96+
.replace("+", "-")
97+
.replace("/", "_")
98+
}
99+
100+
/**
101+
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
102+
*/
103+
private fun descramble(scrambledChallenge: String): String {
104+
return base64ToByteString(scrambledChallenge)
105+
.map { (it + 97).toByte() }
106+
.toByteArray()
107+
.decodeToString()
108+
}
109+
110+
/**
111+
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
112+
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
113+
*/
114+
private fun base64ToU8(base64: String): String {
115+
return newUint8Array(base64ToByteString(base64))
116+
}
117+
118+
private fun newUint8Array(contents: ByteArray): String {
119+
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
120+
}
121+
122+
/**
123+
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
124+
*/
125+
private fun base64ToByteString(base64: String): ByteArray {
126+
val base64Mod = base64
127+
.replace('-', '+')
128+
.replace('_', '/')
129+
.replace('.', '=')
130+
131+
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
132+
.toByteArray()
133+
}

0 commit comments

Comments
 (0)