diff --git a/src/app/services/edge-tts-drm.ts b/src/app/services/edge-tts-drm.ts new file mode 100644 index 000000000..b291eff9c --- /dev/null +++ b/src/app/services/edge-tts-drm.ts @@ -0,0 +1,311 @@ +/** + * Microsoft Edge TTS "Sec-MS-GEC" DRM token, v2 generation. + * + * Microsoft's Read Aloud service requires a `Sec-MS-GEC` token on every + * WebSocket upgrade. The v1 token was plain `SHA256()`; + * the service now rejects it with HTTP 403 and expects a v2 token of the shape + * `<16 hex>-<64 hex>` — a CS64 digest (custom keyed hash over the same input, + * keyed with words derived from a modified SHA-256) joined to a modified + * SHA-256 digest by a dash, plus `Sec-MS-GEC-Version=2-`. + * + * Ported from the Python reference implementation: + * https://github.com/rany2/edge-tts/blob/master/src/edge_tts/drm.py + * + * The reference implementation is written for arbitrary-precision integers and + * masks every intermediate sum; this port keeps the same semantics on 32-bit + * lanes. Multiplications are done with `Math.imul` (the low 32 bits of the + * product) and shifts/XORs are masked with `>>> 0`, so every value stays in + * `[0, 2**32)` exactly like the Python `& 0xFFFFFFFF`. + */ + +const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"; +const WIN_EPOCH_SECONDS = 11_644_473_600; +const ROUND_SECONDS = 300; +const TICKS_PER_UNIX_SECOND = 1e7; + +/** Rotate a 32-bit value left by 16 bits. */ +function rol16(value: number): number { + return (((value << 16) | (value >>> 16)) >>> 0) as number; +} + +/** Rotate a 32-bit value right by `n` bits. */ +function rotr(value: number, n: number): number { + return ((value >>> n) | (value << (32 - n))) >>> 0; +} + +const SHA256_ROUND_CONSTANTS = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +/** Standard constant with index 14 replaced, as the reference does. */ +const SHA256_ADJUSTMENT = 0xc9dc06a7; +const SHA256_BLOCK_CONSTANTS = [ + ...SHA256_ROUND_CONSTANTS.slice(0, 14), + SHA256_ADJUSTMENT, + ...SHA256_ROUND_CONSTANTS.slice(15), +]; +const SHA256_SIGMA0_ADJUSTMENT = 0x059c0b80; +const SHA256_SIGMA1_ADJUSTMENT = 0x000b92c0; + +const SHA256_INITIAL = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; +const SHA256_STATE = [ + 0xfd9fbea2, 0x91243ce7, 0xbe499094, 0xcd2e1e0a, 0x6c006f49, 0x04bd35ab, 0xfeb0a7e4, 0x03effbd2, +]; +const SHA256_SCHEDULE = [ + 0xeb24bf9e, 0x1558691f, 0xa0c09ee2, 0x0cfa32ba, 0xa622ee8c, 0xd1dd9491, 0xc0dd25a0, 0xf9e70ab6, + 0xf0c31573, 0x7058ce4e, 0x7188e606, 0x00e5ab33, 0xd258b060, 0x72766564, +]; + +const CS64_COEFFICIENTS: ReadonlyArray = [ + [0xe82e91b1, 0xb32700d0], + [0x44de24c6, 0x88dff51d], + [0x3beb65bc, 0x29514038], + [0xc89c5933, 0xb328cbdf], + [0x8d1ec211, 0xd405b4d5], + [0x314ef5d5, 0x26e55f3c], + [0xe92491b1, 0x607801c6], + [0xf0a5b9d5, 0x82b2c1fb], + [0x06064cf3, 0xfa51a3da], + [0xda79fa60, 0x207cc464], + [0xbed20560, 0x3c3c74f3], + [0x89f9c9f8, 0xd2d14b61], + [0x35fa38c6, 0xbb04a2c1], + [0xcb49e3c6, 0xc5626167], +]; +const CS64_FACTOR = 0x82893933; +const CS64_ADJUSTMENT0 = 0xf2f90b1a; +const CS64_ADJUSTMENT1 = 0x0b1a0000; + +/** SHA-256 small sigma zero, combining rotations under one mask. */ +function sigma0(value: number): number { + return ((value >>> 7) ^ (value << 25) ^ (value >>> 18) ^ (value << 14) ^ (value >>> 3)) >>> 0; +} + +/** SHA-256 small sigma one, combining rotations under one mask. */ +function sigma1(value: number): number { + return ((value >>> 17) ^ (value << 15) ^ (value >>> 19) ^ (value << 13) ^ (value >>> 10)) >>> 0; +} + +/** + * SHA-256 rounds. `firstRound` resumes mid-block: the reference precomputes the + * first fourteen rounds into the initial state and continues from round 14. + */ +function sha256Rounds( + state: readonly number[], + words: readonly number[], + firstRound = 0, +): number[] { + const [s0, s1, s2, s3, s4, s5, s6, s7] = state; + let a = s0!; + let b = s1!; + let c = s2!; + let d = s3!; + let e = s4!; + let f = s5!; + let g = s6!; + let h = s7!; + const constants = firstRound === 14 ? SHA256_BLOCK_CONSTANTS : SHA256_ROUND_CONSTANTS; + for (let index = firstRound; index < 64; index++) { + const t1 = + (h + + ((rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0) + + ((g ^ (e & (f ^ g))) >>> 0) + + constants[index]! + + words[index]!) >>> + 0; + const t2 = + (((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0) + (((a & b) | (c & (a | b))) >>> 0)) >>> 0; + h = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + return [a, b, c, d, e, f, g, h]; +} + +/** SHA-256 message schedule expansion. */ +function expandSchedule(words: number[], firstWord = 16): void { + for (let index = firstWord; index < 64; index++) { + words.push( + (sigma1(words[index - 2]!) + + words[index - 7]! + + sigma0(words[index - 15]!) + + words[index - 16]!) >>> + 0, + ); + } +} + +/** Builds the modified SHA-256 initial state the reference derives from the 7-byte salt. */ +function sha256Init(data: Buffer): number[] { + const words: number[] = new Array(14).fill(0); + words.push(((data[0]! << 16) | (data[1]! << 8) | data[2]!) >>> 0); + words.push(((data[3]! << 24) | (data[4]! << 16) | (data[5]! << 8) | data[6]!) >>> 0); + for (let index = 16; index < 30; index++) { + let value = SHA256_SCHEDULE[index - 16]! >>> 0; + if (index - 2 >= 14) { + const sigma = sigma1(words[index - 2]!); + value = (value + (index === 16 ? (sigma ^ SHA256_SIGMA1_ADJUSTMENT) >>> 0 : sigma)) >>> 0; + } + if (index - 7 >= 14) { + value = (value + words[index - 7]!) >>> 0; + } + if (index - 15 >= 14) { + const sigma = sigma0(words[index - 15]!); + value = (value + (index === 29 ? (sigma ^ SHA256_SIGMA0_ADJUSTMENT) >>> 0 : sigma)) >>> 0; + } + words.push(value); + } + words.push( + (sigma1(words[28]!) + + words[23]! + + sigma0(words[15]!) + + words[14]! + + SHA256_ADJUSTMENT - + SHA256_ROUND_CONSTANTS[14]!) >>> + 0, + ); + expandSchedule(words, 31); + const working = sha256Rounds(SHA256_STATE, words, 14); + return working.map((value, index) => (value + SHA256_INITIAL[index]!) >>> 0); +} + +/** Modified SHA-256 digest: the first seven bytes seed the state, not the block. */ +function sha256Digest(data: Buffer): Buffer { + const length = 57 + data.length; + const padding = (((55 - length) % 64) + 64) % 64; + const padded = Buffer.concat([ + data.subarray(7), + Buffer.from([0x80]), + Buffer.alloc(padding), + Buffer.alloc(8), + ]); + padded.writeBigUInt64BE(BigInt(length) * 8n, padded.length - 8); + + let state = sha256Init(data.subarray(0, 7)); + for (let offset = 0; offset < padded.length; offset += 64) { + const words: number[] = []; + for (let word = 0; word < 16; word++) { + words.push(padded.readUInt32BE(offset + word * 4)); + } + expandSchedule(words, 16); + const working = sha256Rounds(state, words, 0); + state = state.map((value, index) => (value + working[index]!) >>> 0); + } + + const digest = Buffer.alloc(32); + state.forEach((value, index) => digest.writeUInt32BE(value, index * 4)); + return digest; +} + +/** Splits data into little-endian 32-bit words, zero-padded to an even count. */ +function wordsLe4Even(data: Buffer): number[] { + const padded = Buffer.concat([data, Buffer.alloc((4 - (data.length % 4)) % 4)]); + const words: number[] = []; + for (let offset = 0; offset < padded.length; offset += 4) { + words.push(padded.readUInt32LE(offset)); + } + if (words.length % 2) { + words.push(0); + } + return words; +} + +/** CS64 digest: the custom keyed hash half of the v2 token. */ +function cs64Digest(data: Buffer, digest: Buffer): [number, number] { + const keyA = (digest.readUInt32LE(0) | 1) >>> 0; + const keyB = (digest.readUInt32LE(4) | 1) >>> 0; + const factors = [Math.imul(keyA, CS64_FACTOR) >>> 0, Math.imul(keyB, CS64_FACTOR) >>> 0]; + + const terms: number[] = []; + const offsets: number[] = []; + CS64_COEFFICIENTS.forEach(([coefficient, offset], index) => { + terms.push(Math.imul(coefficient, factors[index % 2]!) >>> 0); + offsets.push(offset >>> 0); + }); + + wordsLe4Even(Buffer.concat([Buffer.from([0]), data])).forEach((word, index) => { + let term = Math.imul(word, index % 2 === 0 ? keyA : keyB) >>> 0; + let offset = Math.imul(rol16(word), index % 2 === 0 ? 0xef0569fb : 0xc31713db) >>> 0; + if (index === 0) { + term = (term + (Math.imul(CS64_ADJUSTMENT0, factors[0]!) >>> 0)) >>> 0; + offset = (offset + CS64_ADJUSTMENT1) >>> 0; + } + terms.push(term); + offsets.push(offset); + }); + + let swapAcc = 0; + let swapLast = 0; + let state = 0; + let acc = 0; + for (let index = 0; index < terms.length; index += 2) { + const x = terms[index]!; + const y = terms[index + 1]!; + + let rotated = rol16((offsets[index]! + x) >>> 0); + let q = Math.imul(rotated, 0x76f50f34) >>> 0; + swapAcc = (swapAcc + (Math.imul(rol16(q), 0xc3efea97) >>> 0) + q) >>> 0; + + rotated = rol16((offsets[index + 1]! + y) >>> 0); + q = Math.imul(rotated, 0x1881ce3c) >>> 0; + swapLast = (Math.imul(rol16(q), 0x35bd1ec9) >>> 0) + q; + swapLast = swapLast >>> 0; + swapAcc = (swapAcc + swapLast) >>> 0; + + state = (Math.imul(state, keyA) + x) >>> 0; + state = Math.imul(rol16(state), 0xcf98b111) >>> 0; + state = Math.imul(rol16(state), 0x87085b9f) >>> 0; + state = Math.imul(rol16(state), 0x12ceb96d) >>> 0; + state = Math.imul(rol16(state), 0x257e1d83) >>> 0; + acc = (acc + state) >>> 0; + + rotated = (Math.imul(state, keyB) + y) >>> 0; + rotated = Math.imul(rol16(rotated), 0xa27416f5) >>> 0; + rotated = Math.imul(rol16(rotated), 0xd38396ff) >>> 0; + state = Math.imul(rol16(Math.imul(rol16(rotated), 0x7c932b89) >>> 0), 0xbfa49f69) >>> 0; + acc = (acc + state) >>> 0; + } + + return [(swapLast ^ state) >>> 0, (swapAcc ^ acc) >>> 0]; +} + +/** Joins the CS64 and modified-SHA-256 digests as `<16 hex>-<64 hex>`. */ +function tokenHash(data: Buffer): string { + const digest = sha256Digest(data); + const cs64 = Buffer.alloc(8); + const [first, second] = cs64Digest(data, digest); + cs64.writeUInt32LE(first, 0); + cs64.writeUInt32LE(second, 4); + return `${cs64.toString("hex").toUpperCase()}-${digest.toString("hex").toUpperCase()}`; +} + +/** + * Generates a v2 `Sec-MS-GEC` token for the given Unix timestamp (seconds). + * + * The timestamp is shifted to the Windows file-time epoch and rounded down to + * the nearest five minutes, matching the reference implementation; the service + * accepts any token valid within the current window. + */ +export function generateSecMsGecV2(unixTimestampSeconds: number): string { + let ticks = unixTimestampSeconds + WIN_EPOCH_SECONDS; + ticks -= ticks % ROUND_SECONDS; + const strToHash = Buffer.from( + `${Math.round(ticks * TICKS_PER_UNIX_SECOND)}${TRUSTED_CLIENT_TOKEN}`, + "ascii", + ); + return tokenHash(strToHash); +} diff --git a/src/app/services/edge-tts.ts b/src/app/services/edge-tts.ts index 056e0895b..41b8a905c 100644 --- a/src/app/services/edge-tts.ts +++ b/src/app/services/edge-tts.ts @@ -1,4 +1,5 @@ -import { createHash, randomBytes, randomUUID } from "crypto"; +import { randomBytes, randomUUID } from "crypto"; +import { generateSecMsGecV2 } from "./edge-tts-drm.js"; import { WebSocket } from "ws"; import { logger } from "../../utils/logger.js"; @@ -20,14 +21,10 @@ const WSS_URL = `wss://${BASE_URL}/edge/v1?TrustedClientToken=${TRUSTED_CLIENT_T const CHROMIUM_FULL_VERSION = "143.0.3650.75"; const CHROMIUM_MAJOR_VERSION = CHROMIUM_FULL_VERSION.split(".")[0]; -export const SEC_MS_GEC_VERSION = `1-${CHROMIUM_FULL_VERSION}`; +export const SEC_MS_GEC_VERSION = `2-${CHROMIUM_FULL_VERSION}`; export const EDGE_DEFAULT_VOICE = "en-US-EmmaMultilingualNeural"; -const WIN_EPOCH_SECONDS = 11644473600; -const TICKS_PER_SECOND = 10_000_000; -const ROUND_SECONDS = 300; - const WSS_HEADERS: Record = { Pragma: "no-cache", "Cache-Control": "no-cache", @@ -48,17 +45,15 @@ let clockSkewSeconds = 0; /** * Generates the Sec-MS-GEC DRM token Microsoft requires on every request. * - * The token is the SHA256 (uppercased hex) of `` where `ticks` - * is the current time as Windows file time (100-ns intervals since 1601-01-01) - * rounded down to the nearest 5 minutes. Rounded to limit token churn; the + * The service no longer accepts the v1 SHA256 token (it answers HTTP 403); the + * current token is the v2 `<16 hex>-<64 hex>` digest over ``, + * where `ticks` is the current time as Windows file time (100-ns intervals + * since 1601-01-01) rounded down to the nearest 5 minutes, and is sent with + * `Sec-MS-GEC-Version=2-`. Rounding limits token churn; the * server accepts any token valid within the current 5-minute window. */ export function generateSecMsGec(now: Date = new Date()): string { - let seconds = now.getTime() / 1000 + clockSkewSeconds + WIN_EPOCH_SECONDS; - seconds -= seconds % ROUND_SECONDS; - const ticks = BigInt(Math.round(seconds)) * BigInt(TICKS_PER_SECOND); - const strToHash = `${ticks}${TRUSTED_CLIENT_TOKEN}`; - return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase(); + return generateSecMsGecV2(now.getTime() / 1000 + clockSkewSeconds); } /** @internal Reset clock skew (for tests only). */ diff --git a/tests/app/services/edge-tts-drm.test.ts b/tests/app/services/edge-tts-drm.test.ts new file mode 100644 index 000000000..6dd7e35f8 --- /dev/null +++ b/tests/app/services/edge-tts-drm.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { generateSecMsGecV2 } from "../../../src/app/services/edge-tts-drm.js"; + +/** + * Vectors produced by the Python reference implementation: + * `edge_tts.drm.generate_sec_ms_gec_v2()`, edge-tts 7.3.1. + * They pin the modified SHA-256, the CS64 keyed hash and the Windows file-time + * rounding, so a regression in any of them fails here first. + */ +const REFERENCE_VECTORS: ReadonlyArray = [ + [0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"], + [1704067200, "9586E5FBB4BD2CFF-DE636BA8369C7431ED826BDEEB00ECA1CC52826A6BB736DCB150A2AF37DFC498"], + [1717243357, "9F3B2BBA43072A65-18E5C3DD3B530033102E8218C71223A2652AF710055462D4D169F51E3D0DFC6B"], + [1717243499, "9F3B2BBA43072A65-18E5C3DD3B530033102E8218C71223A2652AF710055462D4D169F51E3D0DFC6B"], + [1717243500, "34F2A57AEE7BC0B5-99CF975156B5F4EB74B4FEBB5F81D5F9DD3771C0F241237FF948C243634A2324"], + [1761196800, "C9726F91BE3405F5-6D17FAF8C92E338F68EBFBAA0AC39F259811EA871E72452E3CA83E9A8E0C32E9"], + [ + 1761196999.99, + "C9726F91BE3405F5-6D17FAF8C92E338F68EBFBAA0AC39F259811EA871E72452E3CA83E9A8E0C32E9", + ], + [1761197100, "B3FB9133679FD2D3-70F03932790E8A98D1978575BB50358CD6B4506CE6ECA73116CD3D2682CA2B3F"], + [2000000000, "2D62557F1142BC5D-FD9C30C68FF194A5EE6B45FEFC5E2BA1D1239F88A90569AB55CFFFAA7799F40E"], +]; + +describe("generateSecMsGecV2", () => { + it.each(REFERENCE_VECTORS)("matches the Python reference for %s", (timestamp, expected) => { + expect(generateSecMsGecV2(timestamp)).toBe(expected); + }); + + it("returns a 16-hex CS64 joined to a 64-hex SHA-256 by a dash", () => { + expect(generateSecMsGecV2(1704067200)).toMatch(/^[0-9A-F]{16}-[0-9A-F]{64}$/); + }); + + it("is stable inside a five-minute window and changes on the boundary", () => { + expect(generateSecMsGecV2(1717243357)).toBe(generateSecMsGecV2(1717243499)); + expect(generateSecMsGecV2(1717243499)).not.toBe(generateSecMsGecV2(1717243500)); + }); + + it("covers sub-second timestamps before the rounding step", () => { + expect(generateSecMsGecV2(1717243357.42)).toBe(generateSecMsGecV2(1717243357)); + }); +}); diff --git a/tests/app/services/edge-tts.test.ts b/tests/app/services/edge-tts.test.ts index 19da4b2ca..f7ab991db 100644 --- a/tests/app/services/edge-tts.test.ts +++ b/tests/app/services/edge-tts.test.ts @@ -22,15 +22,15 @@ describe("generateSecMsGec", () => { beforeEach(() => _resetClockSkew()); afterEach(() => _resetClockSkew()); - it("produces a 64-char uppercase hex string", () => { + it("produces a v2 token (<16 hex>-<64 hex>)", () => { const token = generateSecMsGec(new Date("2024-01-01T00:00:00Z")); - expect(token).toMatch(/^[0-9A-F]{64}$/); + expect(token).toMatch(/^[0-9A-F]{16}-[0-9A-F]{64}$/); }); it("matches the reference vector for a fixed time", () => { const token = generateSecMsGec(new Date("2024-01-01T00:00:00Z")); expect(token).toBe( - "2AC0A57C1214B9458F8725BB7800499BB594EC29DDA83424BC14661707141F2F", + "9586E5FBB4BD2CFF-DE636BA8369C7431ED826BDEEB00ECA1CC52826A6BB736DCB150A2AF37DFC498", ); }); @@ -48,8 +48,8 @@ describe("generateSecMsGec", () => { }); describe("SEC_MS_GEC_VERSION / EDGE_DEFAULT_VOICE", () => { - it("exposes a Chromium-prefixed GEC version", () => { - expect(SEC_MS_GEC_VERSION).toMatch(/^1-\d+\.\d+\.\d+\.\d+$/); + it("exposes a Chromium-prefixed v2 GEC version", () => { + expect(SEC_MS_GEC_VERSION).toMatch(/^2-\d+\.\d+\.\d+\.\d+$/); }); it("uses a Neural voice as default", () => {