Skip to content

Commit bd7eb30

Browse files
committed
feat(pot): implement proof-of-origin token provider
1 parent dfbdcc4 commit bd7eb30

6 files changed

Lines changed: 290 additions & 3 deletions

File tree

server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { initializeCache } from "./src/playerCache.ts";
44
import { handleDecryptSignature } from "./src/handlers/decryptSignature.ts";
55
import { handleGetSts } from "./src/handlers/getSts.ts";
66
import { handleResolveUrl } from "./src/handlers/resolveUrl.ts";
7+
import { handleGetPoToken } from "./src/handlers/getPoToken.ts";
78
import { withMetrics } from "./src/middleware.ts";
89
import { withValidation } from "./src/validation.ts";
910
import { registry } from "./src/metrics.ts";
@@ -70,6 +71,8 @@ async function baseHandler(req: Request): Promise<Response> {
7071
handle = handleGetSts;
7172
} else if (pathname === '/resolve_url') {
7273
handle = handleResolveUrl;
74+
} else if (pathname === '/get_pot') {
75+
handle = handleGetPoToken;
7376
} else {
7477
return new Response(JSON.stringify({ error: 'Not Found' }), { status: 404, headers: { "Content-Type": "application/json" } });
7578
}

src/handlers/getPoToken.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { potManager } from "../pot.ts";
2+
import type { RequestContext, PoTokenRequest, PoTokenResponse } from "../types.ts";
3+
4+
export async function handleGetPoToken(ctx: RequestContext): Promise<Response> {
5+
const { content_binding } = ctx.body as PoTokenRequest;
6+
7+
try {
8+
const potData = await potManager.generatePoToken(content_binding);
9+
10+
const response: PoTokenResponse = {
11+
poToken: potData.poToken,
12+
visitorData: potData.contentBinding,
13+
expiresAt: potData.expiresAt.toISOString(),
14+
};
15+
16+
return new Response(JSON.stringify(response), {
17+
status: 200,
18+
headers: { "Content-Type": "application/json" },
19+
});
20+
} catch (e) {
21+
console.error("Error generating PoToken:", e);
22+
return new Response(
23+
JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
24+
{
25+
status: 500,
26+
headers: { "Content-Type": "application/json" },
27+
},
28+
);
29+
}
30+
}

src/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type Next = (ctx: RequestContext) => Promise<Response>;
77
export function withMetrics(handler: Next): Next {
88
return async (ctx: RequestContext) => {
99
const { pathname } = new URL(ctx.req.url);
10-
const playerId = extractPlayerId(ctx.body.player_url);
10+
const playerId = extractPlayerId((ctx.body as any)?.player_url);
1111
const pluginVersion = ctx.req.headers.get("Plugin-Version") ?? "unknown";
1212
const userAgent = ctx.req.headers.get("User-Agent") ?? "unknown";
1313

src/pot.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import axios, { AxiosRequestConfig } from "npm:axios";
2+
import {
3+
BG,
4+
BgConfig,
5+
DescrambledChallenge,
6+
WebPoSignalOutput,
7+
FetchFunction,
8+
buildURL,
9+
getHeaders,
10+
USER_AGENT,
11+
} from "npm:bgutils-js";
12+
import { JSDOM } from "npm:jsdom";
13+
import { Innertube, type Context as InnertubeContext } from "npm:youtubei.js";
14+
15+
interface YoutubeSessionData {
16+
poToken: string;
17+
contentBinding: string;
18+
expiresAt: Date;
19+
}
20+
21+
export interface ChallengeData {
22+
interpreterUrl: {
23+
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue: string;
24+
};
25+
interpreterHash: string;
26+
program: string;
27+
globalName: string;
28+
clientExperimentsStateBlob: string;
29+
}
30+
31+
type TokenMinter = {
32+
expiry: Date;
33+
integrityToken: string;
34+
minter: any; // BG.WebPoMinter
35+
};
36+
37+
export class PoTokenManager {
38+
private static readonly REQUEST_KEY = "O43z0dpjhgX20SCx4KAo";
39+
private static hasDom = false;
40+
private _minterCache: Map<string, TokenMinter> = new Map();
41+
private TOKEN_TTL_HOURS = 6;
42+
private innertube?: Innertube;
43+
44+
constructor() {
45+
if (!PoTokenManager.hasDom) {
46+
const dom = new JSDOM(
47+
'<!DOCTYPE html><html lang="en"><head><title></title></head><body></body></html>',
48+
{
49+
url: "https://www.youtube.com/",
50+
referrer: "https://www.youtube.com/",
51+
userAgent: USER_AGENT,
52+
},
53+
);
54+
55+
Object.assign(globalThis, {
56+
window: dom.window,
57+
document: dom.window.document,
58+
location: dom.window.location,
59+
origin: dom.window.origin,
60+
navigator: dom.window.navigator,
61+
});
62+
PoTokenManager.hasDom = true;
63+
}
64+
}
65+
66+
private async getInnertube(): Promise<Innertube> {
67+
if (!this.innertube) {
68+
this.innertube = await Innertube.create({ retrieve_player: false });
69+
}
70+
return this.innertube;
71+
}
72+
73+
private async generateVisitorData(): Promise<string | null> {
74+
try {
75+
const innertube = await this.getInnertube();
76+
const visitorData = innertube.session.context.client.visitorData;
77+
return visitorData || null;
78+
} catch (e) {
79+
console.error("Failed to generate visitor data:", e);
80+
return null;
81+
}
82+
}
83+
84+
private async getDescrambledChallenge(
85+
bgConfig: BgConfig,
86+
innertubeContext?: InnertubeContext,
87+
): Promise<DescrambledChallenge> {
88+
try {
89+
if (!innertubeContext) {
90+
const innertube = await this.getInnertube();
91+
innertubeContext = innertube.session.context;
92+
}
93+
if (!innertubeContext) throw new Error("Innertube context unavailable");
94+
95+
const attGetResponse = await bgConfig.fetch(
96+
"https://www.youtube.com/youtubei/v1/att/get?prettyPrint=false",
97+
{
98+
method: "POST",
99+
headers: {
100+
...getHeaders(),
101+
"Content-Type": "application/json",
102+
},
103+
body: JSON.stringify({
104+
context: innertubeContext,
105+
engagementType: "ENGAGEMENT_TYPE_UNBOUND",
106+
}),
107+
},
108+
);
109+
const attestation = await attGetResponse.json();
110+
const challenge = attestation.bgChallenge as ChallengeData;
111+
112+
const { program, globalName, interpreterHash } = challenge;
113+
const { privateDoNotAccessOrElseTrustedResourceUrlWrappedValue } = challenge.interpreterUrl;
114+
115+
const interpreterJSResponse = await bgConfig.fetch(
116+
`https:${privateDoNotAccessOrElseTrustedResourceUrlWrappedValue}`,
117+
);
118+
const interpreterJS = await interpreterJSResponse.text();
119+
120+
return {
121+
program,
122+
globalName,
123+
interpreterHash,
124+
interpreterJavascript: {
125+
privateDoNotAccessOrElseSafeScriptWrappedValue: interpreterJS,
126+
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue,
127+
},
128+
};
129+
} catch (e) {
130+
console.warn("Failed to get challenge from Innertube, falling back to BG.Challenge.create", e);
131+
const descrambledChallenge = await BG.Challenge.create(bgConfig);
132+
if (descrambledChallenge) return descrambledChallenge;
133+
throw new Error("Could not get Botguard challenge");
134+
}
135+
}
136+
137+
private async generateTokenMinter(
138+
bgConfig: BgConfig,
139+
innertubeContext?: InnertubeContext,
140+
): Promise<TokenMinter> {
141+
const descrambledChallenge = await this.getDescrambledChallenge(bgConfig, innertubeContext);
142+
143+
const { program, globalName } = descrambledChallenge;
144+
const interpreterJavascript = descrambledChallenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
145+
146+
if (interpreterJavascript) {
147+
new Function(interpreterJavascript)();
148+
} else throw new Error("Could not load VM");
149+
150+
const bgClient = await BG.BotGuardClient.create({
151+
program,
152+
globalName,
153+
globalObj: bgConfig.globalObj,
154+
});
155+
156+
const webPoSignalOutput: WebPoSignalOutput = [];
157+
const botguardResponse = await bgClient.snapshot({ webPoSignalOutput });
158+
159+
const integrityTokenResp = await bgConfig.fetch(
160+
buildURL("GenerateIT"),
161+
{
162+
method: "POST",
163+
headers: getHeaders(),
164+
body: JSON.stringify([
165+
PoTokenManager.REQUEST_KEY,
166+
botguardResponse,
167+
]),
168+
},
169+
);
170+
171+
const [integrityToken, estimatedTtlSecs, mintRefreshThreshold, websafeFallbackToken] = await integrityTokenResp.json();
172+
173+
const integrityTokenData = {
174+
integrityToken,
175+
estimatedTtlSecs,
176+
mintRefreshThreshold,
177+
websafeFallbackToken,
178+
};
179+
180+
if (!integrityToken) throw new Error("Unexpected empty integrity token");
181+
182+
const tokenMinter: TokenMinter = {
183+
expiry: new Date(Date.now() + estimatedTtlSecs * 1000),
184+
integrityToken,
185+
minter: await BG.WebPoMinter.create(integrityTokenData, webPoSignalOutput),
186+
};
187+
188+
this._minterCache.set("default", tokenMinter);
189+
return tokenMinter;
190+
}
191+
192+
private getFetch(): FetchFunction {
193+
return async (url: string, options: any): Promise<any> => {
194+
const method = (options?.method || "GET").toUpperCase();
195+
const axiosOpt: AxiosRequestConfig = {
196+
headers: options?.headers,
197+
params: options?.params,
198+
};
199+
200+
const response = await (method === "GET"
201+
? axios.get(url, axiosOpt)
202+
: axios.post(url, options?.body, axiosOpt));
203+
204+
return {
205+
ok: response.status >= 200 && response.status < 300,
206+
status: response.status,
207+
json: async () => response.data,
208+
text: async () => typeof response.data === "string" ? response.data : JSON.stringify(response.data),
209+
};
210+
};
211+
}
212+
213+
async generatePoToken(contentBinding?: string): Promise<YoutubeSessionData> {
214+
if (!contentBinding) {
215+
contentBinding = (await this.generateVisitorData()) || undefined;
216+
if (!contentBinding) throw new Error("Unable to generate visitor data");
217+
}
218+
219+
const bgConfig: BgConfig = {
220+
fetch: this.getFetch(),
221+
globalObj: globalThis as any,
222+
identifier: contentBinding,
223+
requestKey: PoTokenManager.REQUEST_KEY,
224+
};
225+
226+
let tokenMinter = this._minterCache.get("default");
227+
if (!tokenMinter || new Date() >= tokenMinter.expiry) {
228+
const innertube = await this.getInnertube();
229+
tokenMinter = await this.generateTokenMinter(bgConfig, innertube.session.context);
230+
}
231+
232+
const poToken = await tokenMinter.minter.mintAsWebsafeString(contentBinding);
233+
if (!poToken) throw new Error("Unexpected empty POT");
234+
235+
return {
236+
contentBinding,
237+
poToken,
238+
expiresAt: new Date(Date.now() + this.TOKEN_TTL_HOURS * 60 * 60 * 1000),
239+
};
240+
}
241+
}
242+
243+
export const potManager = new PoTokenManager();

src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export interface ResolveUrlResponse {
3636
resolved_url: string;
3737
}
3838

39+
export interface PoTokenRequest {
40+
content_binding?: string;
41+
}
42+
43+
export interface PoTokenResponse {
44+
poToken: string;
45+
visitorData: string;
46+
expiresAt?: string;
47+
}
48+
3949
export interface WorkerWithStatus extends Worker {
4050
isIdle?: boolean;
4151
}
@@ -46,7 +56,7 @@ export interface Task {
4656
reject: (error: any) => void;
4757
}
4858

49-
export type ApiRequest = SignatureRequest | StsRequest | ResolveUrlRequest;
59+
export type ApiRequest = SignatureRequest | StsRequest | ResolveUrlRequest | PoTokenRequest;
5060

5161
// Parsing into this context helps avoid multi copies of requests
5262
// since request body can only be read once.

src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export function validateAndNormalizePlayerUrl(playerUrl: string): string {
2222
throw new Error(`Invalid player URL: ${playerUrl}`);
2323
}
2424
}
25-
export function extractPlayerId(playerUrl: string): string {
25+
export function extractPlayerId(playerUrl?: string): string {
26+
if (!playerUrl) return 'unknown';
2627
try {
2728
const url = new URL(playerUrl);
2829
const pathParts = url.pathname.split('/');

0 commit comments

Comments
 (0)