/ src / core / bda.ts
bda.ts
  1  import { base64 } from "@scure/base";
  2  import { cbc } from "@noble/ciphers/aes.js";
  3  import { md5 } from "@noble/hashes/legacy.js";
  4  import {
  5    hexToBytes,
  6    utf8ToBytes,
  7    concatBytes,
  8    bytesToUtf8,
  9  } from "@noble/ciphers/utils.js";
 10  
 11  export interface EncryptedBDA {
 12    iv: string;
 13    ct: string;
 14    s: string;
 15  }
 16  
 17  type BDAEntryValue = string | number | number[] | string[];
 18  
 19  export interface BDAEntry {
 20    key: string;
 21    value: BDAEntryValue | Array<BDAEntry>;
 22  }
 23  
 24  export class ExpiredBDAError extends Error {
 25    public constructor() {
 26      super("The BDA is expired, please try with a new one.");
 27      this.name = "ExpiredBDAError";
 28    }
 29  }
 30  
 31  export class ArkoseBDA {
 32    public timestamp = Math.floor(Date.now() / 1_000);
 33    public key: Uint8Array;
 34  
 35    public constructor(public readonly userAgent: string) {
 36      this.timestamp = Math.floor(this.timestamp - (this.timestamp % 21_600));
 37      this.key = utf8ToBytes(`${this.userAgent}${this.timestamp}`);
 38    }
 39  
 40    public decrypt({
 41      iv,
 42      ct: ciphertext,
 43      s: salt,
 44    }: EncryptedBDA): Array<BDAEntry> {
 45      try {
 46        // NOTE: we're preallocating the keychain to avoid resizing
 47        const keychain: Array<Uint8Array> = new Array(4);
 48  
 49        {
 50          const saltedKey = concatBytes(this.key, hexToBytes(salt));
 51          keychain[0] = md5(saltedKey);
 52  
 53          for (let i = 0; i < 3; i++) {
 54            keychain[i + 1] = md5(concatBytes(keychain[i], saltedKey));
 55          }
 56        }
 57  
 58        const key = concatBytes(...keychain).slice(0, 32);
 59        const decrypted = cbc(key, hexToBytes(iv)).decrypt(
 60          base64.decode(ciphertext)
 61        );
 62  
 63        return JSON.parse(bytesToUtf8(decrypted));
 64      } catch {
 65        throw new ExpiredBDAError();
 66      }
 67    }
 68  
 69    public static decode(data: string): EncryptedBDA {
 70      return JSON.parse(bytesToUtf8(base64.decode(data.trim())));
 71    }
 72  
 73    public static retrieve(payload: string): URLSearchParams {
 74      // we're reading the raw message of an http request.
 75      if (payload.startsWith("POST /fc/gt2/public_key/")) {
 76        // body of the request
 77        payload = payload.split("\n\n")[1];
 78      }
 79  
 80      return new URLSearchParams(payload);
 81    }
 82  
 83    public static getEnhancedFingerprint(bda: Array<BDAEntry>) {
 84      const { value } = bda.find((entry) => entry.key === "enhanced_fp")!;
 85      return value as Array<BDAEntry>;
 86    }
 87  
 88    public static toObjectProperties(value: Array<BDAEntry>) {
 89      type EntryValueProperty = BDAEntryValue | Record<string, BDAEntryValue>;
 90  
 91      if (Array.isArray(value)) {
 92        // we can't check if the value is an array of BDAEntry
 93        // when the array is empty...
 94        if (value.length === 0) {
 95          return value;
 96        }
 97  
 98        // we should check if the value is an array of BDAEntry
 99        if (
100          !value.every(
101            (item) => typeof item === "object" && "key" in item && "value" in item
102          )
103        ) {
104          return value;
105        }
106      }
107  
108      return value.reduce((acc, entry) => {
109        if (Array.isArray(entry.value)) {
110          acc[entry.key] = this.toObjectProperties(
111            entry.value as Array<BDAEntry>
112          ) as Record<string, BDAEntryValue>;
113          return acc;
114        }
115  
116        acc[entry.key] = entry.value;
117        return acc;
118      }, {} as Record<string, EntryValueProperty>);
119    }
120  }