Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ internal class LocalDomPoTokenGenerator private constructor(
private val tokenWaiters = mutableMapOf<String, TokenWaiter>()
private lateinit var expirationInstant: Instant
@Volatile
private var pageEventId: String? = null
@Volatile
private var closed = false

private fun loadScriptAndInitialize() {
Expand Down Expand Up @@ -142,6 +144,7 @@ internal class LocalDomPoTokenGenerator private constructor(

private fun makeBotguardGetRequest(
url: String,
extraHeaders: Map<String, List<String>> = emptyMap(),
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit,
) {
Expand All @@ -151,10 +154,10 @@ internal class LocalDomPoTokenGenerator private constructor(
?: throw SabrProtocolException("DownloaderImpl is not initialized")
val response = downloader.get(
url,
mapOf(
"User-Agent" to listOf(SharedWebViewRuntime.USER_AGENT),
"Accept" to listOf("*/*"),
),
HashMap(extraHeaders).apply {
put("User-Agent", listOf(attestationContext.userAgent))
put("Accept", listOf("*/*"))
},
)
if (response.responseCode() != 200) {
throw SabrProtocolException(
Expand Down Expand Up @@ -201,41 +204,56 @@ internal class LocalDomPoTokenGenerator private constructor(
}

private fun downloadAndRunBotguard() {
makeBotguardServiceRequest(
"https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false",
buildLocalDomAttestationBody(attestationContext),
contentType = "application/json",
extraHeaders = buildLocalDomAttestationHeaders(
attestationContext,
credentialHeaders,
),
onSuccess = { body ->
try {
val challenge = parseSabrAttChallengeData(body)
val inlineInterpreter = challenge.interpreterJavascript
if (inlineInterpreter != null) {
runBotguard(challenge, inlineInterpreter)
} else {
makeBotguardGetRequest(
requireNotNull(challenge.interpreterUrl),
onSuccess = { runBotguard(challenge, it) },
onError = ::failInitialization,
)
}
} catch (error: Throwable) {
failInitialization(error)
}
// Keep the page-native challenge paired with the EVENT_ID from the same response.
makeBotguardGetRequest(
YOUTUBE_HOME_URL,
extraHeaders = HashMap(credentialHeaders).apply {
put("Accept-Language", listOf("en-US,en;q=0.7"))
},
onSuccess = ::handleYoutubePageBody,
onError = ::failInitialization,
)
}

private fun handleYoutubePageBody(body: String) {
try {
val attestation = parseSabrYoutubePageAttestation(body)
pageEventId = attestation.eventId
handleChallengeBody(attestation.rawChallengeData)
} catch (error: Throwable) {
failInitialization(error)
}
}

private fun handleChallengeBody(body: String) {
try {
val challenge = parseSabrAttChallengeData(body)
val inlineInterpreter = challenge.interpreterJavascript
if (inlineInterpreter != null) {
runBotguard(challenge, inlineInterpreter)
} else {
makeBotguardGetRequest(
requireNotNull(challenge.interpreterUrl),
onSuccess = { runBotguard(challenge, it) },
onError = ::failInitialization,
)
}
} catch (error: Throwable) {
failInitialization(error)
}
}

private fun runBotguard(
challenge: SabrAttChallengeData,
interpreterJavascript: String,
) {
// The page-native BotGuard program reads this while producing its snapshot.
val pageContext = pageEventId?.let {
"window.yt=window.yt||{};window.yt.config_=window.yt.config_||{};" +
"window.yt.config_.EVENT_ID=" + jsString(it) + ";"
}.orEmpty()
runtime.evaluateJavascript(
"pipepipeSabrRunBotguard(" + jsString(sessionId) + ", "
pageContext + "pipepipeSabrRunBotguard(" + jsString(sessionId) + ", "
+ buildSabrAttChallengeData(challenge, interpreterJavascript) + ");",
null,
) { error -> failInitialization(error) }
Expand Down Expand Up @@ -301,6 +319,7 @@ internal class LocalDomPoTokenGenerator private constructor(
private const val TOKEN_TIMEOUT_MS = 30_000L
private const val INIT_TIMEOUT_MS = 60_000L
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private const val YOUTUBE_HOME_URL = "https://www.youtube.com/"

@Throws(SabrProtocolException::class)
fun create(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ internal data class SabrAttChallengeData(
val interpreterUrl: String?,
)

internal data class SabrYoutubePageAttestation(
val eventId: String,
val rawChallengeData: String,
)

internal fun parseSabrYoutubePageAttestation(pageHtml: String): SabrYoutubePageAttestation {
val eventId = EVENT_ID_PATTERN.find(pageHtml)?.groupValues?.get(1)
?: throw IllegalArgumentException("YouTube page has no EVENT_ID")
val call = YT_AT_N_PATTERN.find(pageHtml)
?: throw IllegalArgumentException("YouTube page has no initial attestation call")
val responseProperty = YT_AT_N_RESPONSE_PATTERN.find(pageHtml, call.range.last + 1)
?: throw IllegalArgumentException("YouTube page attestation has no response payload")
val quote = responseProperty.groupValues[1].single()
val rawChallengeData = decodeJavascriptString(
pageHtml,
responseProperty.range.last + 1,
quote,
)
parseSabrAttChallengeData(rawChallengeData)
return SabrYoutubePageAttestation(eventId, rawChallengeData)
}

internal fun parseSabrAttChallengeData(rawAttestationData: String): SabrAttChallengeData {
val challenge = JsonParser.`object`().from(rawAttestationData).getObject("bgChallenge")
val interpreterJavascript = challenge.getObject("interpreterJavascript")
Expand Down Expand Up @@ -85,3 +107,54 @@ private fun base64ToByteArray(base64: String): ByteArray {
.replace('.', '=')
return Base64.getDecoder().decode(normalized)
}

private fun decodeJavascriptString(source: String, start: Int, quote: Char): String {
val result = StringBuilder()
var index = start
while (index < source.length) {
val character = source[index++]
if (character == quote) {
return result.toString()
}
if (character != '\\') {
result.append(character)
continue
}
require(index < source.length) { "Incomplete JavaScript string escape" }
when (val escaped = source[index++]) {
'b' -> result.append('\b')
'f' -> result.append('\u000C')
'n' -> result.append('\n')
'r' -> result.append('\r')
't' -> result.append('\t')
'v' -> result.append('\u000B')
'x' -> {
result.append(readJavascriptHex(source, index, 2).toChar())
index += 2
}
'u' -> {
result.append(readJavascriptHex(source, index, 4).toChar())
index += 4
}
'\n' -> Unit
'\r' -> if (index < source.length && source[index] == '\n') index++
else -> result.append(escaped)
}
}
throw IllegalArgumentException("Unterminated JavaScript string")
}

private fun readJavascriptHex(source: String, start: Int, length: Int): Int {
require(start + length <= source.length) { "Incomplete hexadecimal escape" }
var value = 0
repeat(length) { offset ->
val digit = source[start + offset].digitToIntOrNull(16)
?: throw IllegalArgumentException("Invalid hexadecimal escape")
value = value * 16 + digit
}
return value
}

private val EVENT_ID_PATTERN = Regex("\\\"EVENT_ID\\\"\\s*:\\s*\\\"([A-Za-z0-9_-]+)\\\"")
private val YT_AT_N_PATTERN = Regex("""window\.ytAtN\s*\(""")
private val YT_AT_N_RESPONSE_PATTERN = Regex("""['"]R['"]\s*:\s*(['"])""")
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.schabi.newpipe.player.datasource

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

Expand Down Expand Up @@ -38,6 +39,38 @@ class LocalDomPoTokenRequestTest {
)
}

@Test
fun parsesPageNativeAttestationAndEventId() {
val challenge = """{"bgChallenge":{"program":"program","globalName":"global","interpreterJavascript":{"privateDoNotAccessOrElseSafeScriptWrappedValue":"script"}}}"""
val escapedChallenge = challenge.toByteArray().joinToString("") {
"\\x%02x".format(it.toUByte().toInt())
}
val page = """<script>ytcfg.set({"EVENT_ID":"event_123-abc"});window.ytAtN({'R':'$escapedChallenge'});</script>"""

assertEquals(
SabrYoutubePageAttestation("event_123-abc", challenge),
parseSabrYoutubePageAttestation(page),
)
}

@Test
fun rejectsPageAttestationWithoutEventId() {
val page = """<script>window.ytAtN({'R':'{}'});</script>"""

assertThrows(IllegalArgumentException::class.java) {
parseSabrYoutubePageAttestation(page)
}
}

@Test
fun rejectsPageAttestationWithoutInitialChallenge() {
val page = """<script>ytcfg.set({"EVENT_ID":"event_123"});</script>"""

assertThrows(IllegalArgumentException::class.java) {
parseSabrYoutubePageAttestation(page)
}
}

@Test
fun cacheIdentityDoesNotCrossClientContexts() {
assertNotEquals(
Expand Down