Sonora 如何获取 YouTube Music PO Token:逆向 webview 一次性会话机制的完整解析
Sonora 如何获取 YouTube Music PO Token:逆向 webview 一次性会话机制的完整解析
Sonora 是一款用 Rust + GPUI 构建的原生音乐流媒体客户端,支持 Apple Music、Spotify、YouTube Music、Deezer、Subsonic 及本地播放。当你在 Sonora 里听 YouTube Music 时,如果播放地址被 Google 标记,普通 API 请求会被拒绝——此时需要一枚 PO Token(Proof-of-Origin Token,"来自浏览器"的凭证)。Sonora 的做法很特别:它不模拟浏览器,而是直接逆向 YouTube 网页播放器的流程,在一个隐藏的原生 webview 窗口里用真实浏览器引擎完成签发,再通过"一次性会话(ephemeral session)"保证安全与干净。本文带你完整拆解这套机制。
为什么 PO Token 必须靠真浏览器引擎签发?
先理解背景:Google 的 BotGuard 虚拟机会在你请求流媒体地址时校验请求来源。这个虚拟机会对运行它的 JavaScript 环境做"真实性证明(attestation)"——检测引擎指纹、事件行为等,只有通过校验才把凭证换回来。
手写 JS 环境(如纯 HTTP 客户端模拟)过不了这一关,所以 Sonora 的策略是:用应用自带的真实浏览器引擎去走一遍 YouTube 网页播放器自己走的流程。
💡 关键洞察:Sonora 不是"破解"算法,而是复用官方 Web 页面的整套签发链路,只是把结果悄悄截走。
总体架构:三方交接,播放绝不阻塞
机制由三层组成,各自独立、互不感知细节:
| 模块 | 文件 | 职责 |
|---|---|---|
| 令牌中介 | potoken.rs | 内存中的"信箱":客户端记录需求、领取令牌,并管理失败重试 |
| 签发实体 | potoken.rs | GPUI 实体,监听需求、开隐藏窗口、轮询结果 |
| 页面脚本 | potoken.js | 注入隐藏窗口,驱动 BotGuard 完成签发 |
播放路径上的交接逻辑(见 potoken.rs 的模块注释):
- 客户端需要令牌时调用
token(binding):有新鲜令牌就立刻返回;没有就只记录需求,马上返回空——"任何一首歌都不应该等一个浏览器窗口"。 - 客户端先用一个**冷启动令牌(cold start token)**开播,它足以撑过歌曲开头。
- 应用侧的
PoToken实体每 500ms 轮询一次信箱,发现需求就开窗签发。 - 签发完成后经
give()入库,有效期 6 小时(Google 的 integrity token 本身能活 12 小时,所以令牌绝不会先于签发方过期);下一首歌就用上真令牌。
YouTube 客户端接入点在 youtube/mod.rs:
- 已登录会话与访客会话都通过
mint_po_tokens(Arc::new(Minter))挂接同一个Minter,其mint方法只是转发到music::potoken::token(binding)。 binding是"需要令牌的标识"(与某次流请求绑定),由底层 ytmusic 客户端在需要时传入。
一次性会话(Throwaway Session):webview 的核心设计
真正的"窗口"能力由 crates/webview/ 提供,它的库注释开门见山:"A native browser window with a throwaway session"。
核心特性(lib.rs):
- 会话与窗口同生共死:窗口加载页面所用的数据存储只活到窗口关闭。以 Linux 后端为例,它通过 WebKitGTK 的
webkit_web_context_new_ephemeral()创建一次性上下文(linux.rs 注释:"dropping it at the end takes the cookies with it")。 - 不污染任何真实浏览器:没有浏览器 profile 会持有这个会话,也就不会出现"共享浏览器会话在你背后偷偷轮换 cookie"的问题。
- 两种窗口形态:
Target结构体用script字段区分——带脚本的是"脚本化窗口"(隐藏、永不显示给用户),不带的是"登录窗口"(展示给用户手动登录)。 - 结果回传通道只有一个:Cookie。宿主对页能做的一切就是读 cookie,所以签发脚本把答案写进名为
SONORA_POT的 cookie,主程序每轮poll()检查它,读到有效值就取回Cookie头并自动关窗。 - 平台支持:macOS / Windows / Linux 有原生后端(分别为 WKWebView、WebView2、WebKitGTK);其他平台
supported() == false,客户端自动降级——不开窗、不签发,只用冷启动令牌,或让用户粘贴请求头。
potoken.js:BotGuard 签发的完整逆向流程
这是全文最硬核的部分。potoken.js 是一个 IIFE,被 include_str! 直接编译进二进制,注入隐藏窗口。它复刻了 YouTube 网页播放器自己的 6 步签发流程:
第 1 步:截获 BotGuard 挑战。
页面通过调用 window.ytAtN 这个 resolver 发布 BotGuard 挑战,随后在 DOMContentLoaded 时删掉它。脚本用 Object.defineProperty 在二者之间"站岗":set 时存下 resolver,get 时返回一个既记录 payload 又原样转发的 relay——无论发布与删除谁先发生,payload 都逃不掉。
第 2 步:加载解释器。
挑战 payload 里有 program 与 interpreterUrl。脚本像页面自己的播放器一样,用 <script> 标签加载解释器——而不是 fetch(google.com 不给跨域权限),也不是 new Function(页面的内容策略禁止 eval)。
第 3 步:Trusted Types 合规。
YouTube 页面启用了 Trusted Types,脚本 URL 不能是普通字符串。脚本自己创建名为 sonora-potoken 的 policy 来铸造 URL,与页面自有脚本走同一条路。
第 4 步:attestation 快照。
拿到解释器后,脚本调用 vm.a(program, callback, ...) 运行 BotGuard 程序,回调交出 snapshot / shutdown / passEvent / checkCamera 四个函数;然后 snapshot 回传一个 token,并把 signals[0](minter 工厂)留存在数组里。不被信任的引擎会得到空数组——这就是"只有真引擎能过"的判定点。
第 5 步:换取 integrity token。
签发所需的 4 个常量故意不硬编码,全部从页面自己的播放器脚本(PLAYER_JS_URL,同源可从缓存命中)里正则读出:
| 常量 | 来源模式 |
|---|---|
| request key | html5_web_po_request_key 实验分支里的第二个字面量 |
| API key | "X-Goog-Api-Key"] : "AIzaSy..." |
| 反滥用主机 | https://...-pa.googleapis.com |
| RPC 路径 | /google.internal.waa.v1.Waa/GenerateIT |
作者特意说明:模式失配只会在 Google 移动了东西时发生,此时"凭记忆的旧值"反而是过期数据,不如诚实地失败并在日志里说清楚缺哪一块——因为签发不在播放关键路径上,冷启动令牌兜底,失败一次只损失一个重试。
拿到常量后,POST 到 <host>/$rpc<...GenerateIT>(application/json+protobuf,带 x-goog-api-key 头),body 是 [request_key, attestation] 的 JSON,返回的 base64url 字节就是 integrity token。Google 对不受信任的浏览器只发 fallback token,不发 integrity token。
第 6 步:铸造 PO Token 并回传。
signals0 产出 minter 函数,minter(encode(binding)) 得到原始令牌字节,转成 base64url 写入 cookie:
SONORA_POT=<base64url token>; path=/; max-age=120
失败则写成 SONORA_POT=!<原因>——因为令牌是 base64url 编码、绝不可能以 ! 开头,宿主只需看首字符即可区分成败。
几个工程细节值得注意:
- 不等 load 事件:无人看管的 YouTube 页面可能加载很久,而挑战远早于页面结束加载就到达,脚本直接开始轮询挑战。
- binding 存 sessionStorage:页面加载后会重写自己的 URL 甚至跳转,所以脚本在 document start 就读出
?binding=并存入sessionStorage,后续页面也能取到。 - 幂等:若上一跳已留下有效令牌 cookie,脚本直接不再签发。
宿主侧:轮询、耐心值与失败退避
应用侧的 PoToken 实体(state/potoken.rs)逻辑非常克制:
- 500ms 一个 tick:没有页面时尝试"开门",有页面时推进轮询。
- 开门:从信箱
wanted()取走需求("取走"而非"读取",一次需求只触发一次尝试),拼出https://www.youtube.com/?binding=<urlencoded>,连同potoken.js一起交给webview::Page::open。 - 45 秒耐心值:Google 往返通常一两秒,45 秒还没动静就放弃记为失败。
- 失败指数退避(music/potoken.rs):首次失败后等 5 秒再试,每次连续失败翻倍,上限 5 分钟。注释解释得很透:最可能的失败是"页面没加载完",很快重试就能修复;而"Google 就是不认这个浏览器"则次次失败,翻倍增长避免了"每次运行期间每隔几秒弹一个窗口"的灾难。成功一次后失败计数清零,下一轮退避从头开始。
为什么这套设计对普通用户是透明的?
把上面所有机制对用户的实际影响总结成三点:
- 播放零等待:需求记录即返回,冷启动令牌保证"歌曲开头就能出声",真令牌在后台到位,下一首无缝升级。
- 看不到任何弹窗:签发窗口是隐藏脚本化窗口,不是登录窗口,不进入用户的视野;Linux 上若 webkit2gtk 加载不了(应用启动时会用
--webview-probe子进程探测),干脆不开窗、安静降级。 - 不留下浏览器痕迹:一次性会话意味着这次"访问 YouTube"不写入任何 profile,cookie 随窗口销毁,没有会话轮换、没有残留状态。
延伸阅读
- 令牌信箱与退避策略:crates/music/src/potoken.rs
- 签发实体(轮询与开窗):crates/state/src/potoken.rs
- BotGuard 逆向脚本:crates/state/src/potoken.js
- 一次性会话 webview 核心:crates/webview/src/lib.rs、Linux 后端 crates/webview/src/linux.rs
- YouTube 客户端接入(
mint_po_tokens):crates/music/src/youtube/mod.rs - 项目贡献指南:CONTRIBUTING.md、开发说明 AGENTS.md
📌 一句话总结:Sonora 获取 YouTube Music PO Token 的秘诀不是模拟,而是"借道"——用一个活在与窗口相同生命周期的真实浏览器引擎,跑一遍 YouTube 官方播放器自己的 BotGuard 签发流程,把令牌从 cookie 里接走;配合冷启动令牌与指数退避,让整个过程对播放路径完全无感。