Skip to content

Commit

Permalink
feat: Implement proper botguard client and potoken minter (#9)
Browse files Browse the repository at this point in the history
* feat: Implement proper botguard client and potoken minter

* chore: update docs
  • Loading branch information
LuanRT authored Oct 18, 2024
1 parent 871beaf commit fddd47f
Show file tree
Hide file tree
Showing 10 changed files with 434 additions and 318 deletions.
128 changes: 87 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,77 +28,116 @@ Below is a brief overview of the process to generate a PoToken for those interes

### Initialization Process

To initialize the BotGuard VM, we must first retrieve its script and challenge:
First, retrieve the VM's script and program:
```shell
curl --request POST \
--url 'https://jnn-pa.googleapis.com/$rpc/google.internal.waa.v1.Waa/Create' \
--header 'Content-Type: application/json+protobuf' \
--header 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36(KHTML, like Gecko)' \
--header 'x-goog-api-key: AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw' \
--header 'x-user-agent: grpc-web-javascript/0.1' \
--data '[ "requestKeyHere" ]'
```

Once the data from the request is available, it must be descrambled and parsed:
Once the response data is available, it must be descrambled and parsed:
```js
// ...
const buffer = base64ToU8(scrambledChallenge);
const descrambled = new TextDecoder().decode(buffer.map((b) => b + 97));
const challengeData = JSON.parse(descrambled);
```

The descrambled data should consist of a message ID, the interpreter javascript, the interpreter hash, a program, and the script's global name.
The descrambled data should consist of a message ID, the interpreter JavaScript, the interpreter hash, a program, and the script's global name.

To make the VM available in the global scope, evaluate the script. If all goes well, you should be able to access the VM from your browser or program.
To make the VM available, evaluate the script:
```js
const interpreterJavascript = bgChallenge.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;

if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
```

If everything goes well, you should be able to access it like so:
```js
const globalObject = window || globalThis;
console.log(globalObject[challengeData.globalName]);
```

### Retrieving Integrity Token

This is a very important step. The Integrity Token is retrieved from an attestation server and takes the result of the BotGuard challenge, likely to evaluate the integrity of the runtime environment. To "solve" the challenge, you must invoke BotGuard and use the program we retrieved as its first parameter:
This is a crucial step. The Integrity Token is retrieved from an attestation server and relies on the result of the BotGuard challenge, likely to assess the integrity of the runtime environment. To "solve" this challenge, you need to invoke BotGuard and pass the retrieved program as its first argument.

```js
// ...
if (!vm.a)
throw new BGError(2, "[BG]: Init failed");
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 = (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) => {
Object.assign(this.vmFunctions, { asyncSnapshotFunction, shutdownFunction, passEventFunction, checkCameraFunction });
};

try {
await vm.a(program, attFunctionsCallback, true, undefined, () => {/** no-op */ });
} catch (err) {
throw new BGError(3, `[BG]: Failed to load program: ${err.message}`);
this.syncSnapshotFunction = await this.vm.a(this.program, vmFunctionsCallback, true, undefined, () => { /** no-op */ }, [ [], [] ])[0];
} catch (error) {
throw new Error(`[BotGuardClient]: Failed to load program (${(error as Error).message})`);
}
```

The second parameter should point to a callback function, where BotGuard will return another function that will later be used to retrieve the payload for the integrity token request.
The second parameter should be a callback function, where BotGuard will return several functions. In our case, we are mainly interested in `asyncSnapshotFunction`.

Once that function is available, call it with the following arguments:
1. A callback function with one argument. This function will return the token for the attestation request.
2. An array with four items. You can leave most of them as undefined/null, except for the third one, which should point to an array. BotGuard will fill it with one or more functions if the challenge is successfully solved.
Once `asyncSnapshotFunction` is available, call it with the following arguments:
1. A callback function that takes a single argument. This function will return the token for the attestation request.
2. An array with four elements:
- 1st: `contentBinding` (Optional).
- 2nd: `signedTimestamp` (Optional).
- 3rd: `webPoSignalOutput` (Optional but required for our case, BotGuard will fill this array with a function to get a PoToken minter).
- 4th: `skipPrivacyBuffer` (Optional, not sure what this one is/does).

```js
// ...
/** @type {string | null} */
let botguardResponse = null;
/** @type {(PostProcessFunction | undefined)[]} */
let postProcessFunctions = [];
async snapshot(args) {
return new Promise((resolve, reject) => {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));

this.vmFunctions.asyncSnapshotFunction((response) => resolve(response), [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
```

await attFunctions.fn1((response) => botguardResponse = response, [, , postProcessFunctions,]);
Then:
```js
const webPoSignalOutput = [];
const botguardResponse = await snapshot({ webPoSignalOutput });
```

If everything was done correctly so far, you should have a token and an array with one or more functions.
If everything was done correctly, you should have a token and an array with one or more functions.

Now we can create the payload for the Integrity Token request. It should be an array of two items: the request key and the token.

Now we can create the payload for the request we'll be making next. It should consist of an array with two items: the first should be the request key, and the second should be the token we just got:

```shell
curl --request POST \
--url 'https://jnn-pa.googleapis.com/$rpc/google.internal.waa.v1.Waa/GenerateIT' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json+protobuf' \
--header 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36(KHTML, like Gecko)' \
--header 'x-goog-api-key: AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw' \
--header 'x-user-agent: grpc-web-javascript/0.1' \
--data '[ "requestKeyHere", "$abcdeyourtokenhere" ]'
--data '[ "requestKeyHere", "$abcdeyourbotguardtokenhere" ]'
```

If the API call is successful, you will get a JSPB response (json+protobuf) that looks like this:
If the API call is successful, you will receive a JSPB (json+protobuf) response that looks like this:
```json
[
"azXvdvYQKz8ff4h9PjIlQI7JUOTtYnBdXEGs4bmQb8FvmFB+oosILg6flcoDfzFpwas/hitYcUzx3Qm+DFtQ9slN",
Expand All @@ -107,41 +146,48 @@ If the API call is successful, you will get a JSPB response (json+protobuf) that
]
```

The first item is the integrity token, the second one is the TTL (Time to Live), and the third is the refresh threshold.
The first item is the Integrity Token, the second is the TTL (Time to Live), and the third is the refresh threshold.

Store the token and the first function from the array we got earlier. We'll use them to construct the PoToken.
Store the token and the array we obtained earlier. We'll use them to construct the PoToken.

### Generating a PoToken

First, call the function from the last step using the integrity token (in bytes) as an argument.
Call the first function in the `webPoSignalOutput` array with the Integrity Token (in bytes) as an argument:

```js
const processIntegrityToken = bg.postProcessFunctions[0];
const getMinter = webPoSignalOutput[0];

if (!getMinter)
throw new Error('PMD:Undefined');

if (!processIntegrityToken)
throw new BGError(4, "PMD:Undefined");
const mintCallback = await getMinter(base64ToU8(integrityTokenResponse.integrityToken ?? ''));

const acquirePo = await processIntegrityToken(base64ToU8(bg.integrityToken));
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
```
If this call succeeds, you should get another function. Call it with your Visitor ID (or Data Sync ID if you're signed in) as its first argument.
If successful, you'll receive a function to mint PoTokens. Call it with your Visitor ID (or Data Sync ID if you're signed in) as an argument:
```js
const buffer = await acquirePo(new TextEncoder().encode(identifier));
const result = await mintCallback(new TextEncoder().encode(identifier));

if (!result)
throw new Error('YNJ:Undefined');

const poToken = u8ToBase64(buffer, true);
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');

if (poToken.length > 80)
return poToken;
const poToken = u8ToBase64(result, true);
console.log(poToken);
```
The result will be a sequence of bytes, with a length of around 110-128 bytes. Base64 encode it, and you'll have your PoToken!
The result will be a sequence of bytes, about 110128 bytes in length. Base64 encode it, and you'll have a PoToken!
### When to Use a PoToken
YouTube's web player checks the "sps" (`StreamProtectionStatus`) of each media segment request (only if using `UMP` or `SABR`; our browser example uses `UMP`) to determine if the stream needs a PoToken.
- **Status 1**: The stream is either already using a PoToken or does not need one.
- **Status 2**: The stream requires a PoToken but will allow the client to request up to 1-2MB of data before interrupting playback.
- **Status 2**: The stream requires a PoToken but will allow the client to request up to 1-2 MB of data before interrupting playback.
- **Status 3**: The stream requires a PoToken and will interrupt playback immediately.
## License
Expand Down
107 changes: 107 additions & 0 deletions src/core/botGuardClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { BotGuardClientOptions, SnapshotArgs, VMFunctions } from '../utils/types.js';

export default class BotGuardClient {
public vm: Record<string, any>;
public program: string;
public userInteractionElement?: Record<string, any>;
public vmFunctions: VMFunctions = {};
public syncSnapshotFunction?: (args: any[]) => Promise<string>;

constructor(options: BotGuardClientOptions) {
this.userInteractionElement = options.userInteractionElement;
this.vm = options.globalObj[options.globalName];
this.program = options.program;
}

public static async create(options: BotGuardClientOptions) {
return await new BotGuardClient(options).load();
}

private 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 = (
asyncSnapshotFunction: VMFunctions['asyncSnapshotFunction'],
shutdownFunction: VMFunctions['asyncSnapshotFunction'],
passEventFunction: VMFunctions['passEventFunction'],
checkCameraFunction: VMFunctions['checkCameraFunction']
) => {
Object.assign(this.vmFunctions, { asyncSnapshotFunction, shutdownFunction, passEventFunction, checkCameraFunction });
};

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 as Error).message})`);
}

return this;
}

/**
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
public async snapshot(args: SnapshotArgs): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));

this.vmFunctions.asyncSnapshotFunction((response) => resolve(response), [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}

public async invoke(args: SnapshotArgs) {
if (!this.syncSnapshotFunction)
throw new Error('[BotGuardClient]: Sync snapshot function not found');

const result = await this.syncSnapshotFunction([
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);

return result;
}

public passEvent(args: unknown) {
if (!this.vmFunctions.passEventFunction)
throw new Error('[BotGuardClient]: Pass event function not found');

this.vmFunctions.passEventFunction(args);
}

public checkCamera(args: unknown) {
if (!this.vmFunctions.checkCameraFunction)
throw new Error('[BotGuardClient]: Check camera function not found');

this.vmFunctions.checkCameraFunction(args);
}

public shutdown() {
if (!this.vmFunctions.shutdownFunction)
throw new Error('[BotGuardClient]: Shutdown function not found');

this.vmFunctions.shutdownFunction();
}
}
24 changes: 11 additions & 13 deletions src/core/challenge.ts → src/core/challengeFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BGError, base64ToU8, BASE_URL, GOOG_API_KEY, USER_AGENT } from '../utils/index.js';
import { base64ToU8, buildURL, GOOG_API_KEY } from '../utils/index.js';
import type { DescrambledChallenge, BgConfig } from '../utils/index.js';

/**
Expand All @@ -11,30 +11,26 @@ import type { DescrambledChallenge, BgConfig } from '../utils/index.js';
export async function create(bgConfig: BgConfig, interpreterHash?: string): Promise<DescrambledChallenge | undefined> {
const requestKey = bgConfig.requestKey;

if (!requestKey)
throw new BGError(0, '[Challenge]: Request key not provided');

if (!bgConfig.fetch)
throw new BGError(1, '[Challenge]: Fetch function not provided');
throw new Error('[Challenge]: Fetch function not provided');

const payload = [ requestKey ];

if (interpreterHash)
payload.push(interpreterHash);

const response = await bgConfig.fetch(new URL('/$rpc/google.internal.waa.v1.Waa/Create', BASE_URL), {
const response = await bgConfig.fetch(buildURL('Create', bgConfig.useYouTubeAPI), {
method: 'POST',
headers: {
'Content-Type': 'application/json+protobuf',
'User-Agent': USER_AGENT,
'X-Goog-Api-Key': GOOG_API_KEY,
'X-User-Agent': 'grpc-web-javascript/0.1'
'content-type': 'application/json+protobuf',
'x-goog-api-key': GOOG_API_KEY,
'x-user-agent': 'grpc-web-javascript/0.1'
},
body: JSON.stringify(payload)
});

if (!response.ok)
throw new BGError(2, `[Challenge]: Failed to fetch challenge: ${response.status}`);
throw new Error(`[Challenge]: Failed to fetch challenge: ${response.status}`);

const rawData = await response.json() as unknown[];

Expand All @@ -54,14 +50,16 @@ export function parseChallengeData(rawData: Record<string, any>): DescrambledCha
challengeData = rawData[0];
}

const [ messageId, wrappedScript, , interpreterHash, program, globalName, , clientExperimentsStateBlob ] = challengeData;
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
privateDoNotAccessOrElseSafeScriptWrappedValue,
privateDoNotAccessOrElseTrustedResourceUrlWrappedValue
},
interpreterHash,
program,
Expand Down
6 changes: 4 additions & 2 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * as Challenge from './challenge.js';
export * as PoToken from './potoken.js';
export * as Challenge from './challengeFetcher.js';
export * as PoToken from './webPoClient.js';
export * as WebPoMinter from './webPoMinter.js';
export { default as BotGuardClient } from './botGuardClient.js';
Loading

0 comments on commit fddd47f

Please sign in to comment.