From f00c4858b46a737a7157ce2e5844808a7476800f Mon Sep 17 00:00:00 2001 From: 7ui77 <7ui77@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:01:47 +0700 Subject: [PATCH] fix(fenrir): support read-token rotation and encrypted chapter decryption --- plugins/english/fenrirrealm.ts | 753 ++++++++++++++++++++++++++++----- 1 file changed, 638 insertions(+), 115 deletions(-) diff --git a/plugins/english/fenrirrealm.ts b/plugins/english/fenrirrealm.ts index a758f4db4..2e8eb76d3 100644 --- a/plugins/english/fenrirrealm.ts +++ b/plugins/english/fenrirrealm.ts @@ -4,6 +4,199 @@ import { load as loadCheerio } from 'cheerio'; import { Filters, FilterTypes } from '@libs/filterInputs'; import { storage } from '@libs/storage'; import { defaultCover } from '@libs/defaultCover'; +import { gcm } from '@libs/aes'; + +const CONTENT_SALT_V1 = 'fr-content-v1'; +const FRAG_SALT_V1 = 'fr-frag-v1'; +const CONTENT_SALT_V2 = 'fr-content-v2'; +const FRAG_SALT_V2 = 'fr-frag-v2'; +const AES_GCM_IV_LENGTH = 12; +const AES_GCM_TAG_LENGTH = 16; + +const SHA256_K = new Uint32Array([ + 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, +]); + +function rotr32(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +function sha256Bytes(input: string): Uint8Array { + const bytes = new TextEncoder().encode(input); + const bitLen = bytes.length * 8; + const padLen = (((bytes.length + 8) >> 6) + 1) << 6; + const buf = new Uint8Array(padLen); + buf.set(bytes); + buf[bytes.length] = 0x80; + const view = new DataView(buf.buffer); + view.setUint32(padLen - 4, bitLen >>> 0, false); + view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000) >>> 0, false); + + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + const w = new Uint32Array(64); + for (let i = 0; i < padLen; i += 64) { + for (let j = 0; j < 16; j++) { + w[j] = view.getUint32(i + j * 4, false); + } + for (let j = 16; j < 64; j++) { + const s0 = + rotr32(w[j - 15], 7) ^ rotr32(w[j - 15], 18) ^ (w[j - 15] >>> 3); + const s1 = + rotr32(w[j - 2], 17) ^ rotr32(w[j - 2], 19) ^ (w[j - 2] >>> 10); + w[j] = (w[j - 16] + s0 + w[j - 7] + s1) >>> 0; + } + + let a = h0; + let b = h1; + let c = h2; + let d = h3; + let e = h4; + let f = h5; + let g = h6; + let h = h7; + + for (let j = 0; j < 64; j++) { + const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + SHA256_K[j] + w[j]) >>> 0; + const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + outView.setUint32(0, h0, false); + outView.setUint32(4, h1, false); + outView.setUint32(8, h2, false); + outView.setUint32(12, h3, false); + outView.setUint32(16, h4, false); + outView.setUint32(20, h5, false); + outView.setUint32(24, h6, false); + outView.setUint32(28, h7, false); + return out; +} + +function xorshift128Next(state: Uint32Array): number { + const t = state[0]; + let r = (t ^ (t << 11)) >>> 0; + r = (r ^ (r >>> 8)) >>> 0; + const o = state[3]; + state[0] = state[1]; + state[1] = state[2]; + state[2] = o; + const f = (o ^ (o >>> 19) ^ r) >>> 0; + state[3] = f; + return f; +} + +function unshuffleFragments(fragments: string[], seedStr: string): string { + const count = fragments.length; + if (count < 2) return count === 1 ? fragments[0] : ''; + const digest = sha256Bytes(seedStr); + const view = new DataView( + digest.buffer, + digest.byteOffset, + digest.byteLength, + ); + const state = new Uint32Array([ + view.getUint32(0, true), + view.getUint32(4, true), + view.getUint32(8, true), + view.getUint32(12, true), + ]); + const perm = new Array(count); + for (let i = 0; i < count; i++) { + perm[i] = i; + } + for (let i = count - 1; i > 0; i--) { + const j = xorshift128Next(state) % (i + 1); + const tmp = perm[i]; + perm[i] = perm[j]; + perm[j] = tmp; + } + const restored = new Array(count); + for (let i = 0; i < count; i++) { + restored[perm[i]] = fragments[i]; + } + return restored.join(''); +} + +function decodeBase64Bytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +type ReadTokenPayload = { + vid: string; + cid: number | null; + exp: number | null; +}; + +function parseReadTokenPayload(token: string): ReadTokenPayload { + try { + const payloadPart = token.split('.')[0] ?? ''; + if (!payloadPart) return { vid: '', cid: null, exp: null }; + const b64 = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const decoded = new TextDecoder().decode(decodeBase64Bytes(padded)); + const parsed = JSON.parse(decoded); + return { + vid: typeof parsed?.vid === 'string' ? parsed.vid : '', + cid: + typeof parsed?.cid === 'number' && parsed.cid > 0 ? parsed.cid : null, + exp: + typeof parsed?.exp === 'number' && parsed.exp > 0 ? parsed.exp : null, + }; + } catch { + return { vid: '', cid: null, exp: null }; + } +} + +function extractVidFromReadToken(token: string): string { + return parseReadTokenPayload(token).vid; +} type APINovel = { title: string; @@ -20,6 +213,7 @@ type APINovel = { type APIChapter = { id: number; + uuid?: string | null; locked: { price: number } | null; group: null | { index: number; @@ -31,6 +225,35 @@ type APIChapter = { created_at: string; }; +type APIChapterData = { + id: number; + uuid?: string | null; + slug?: string; + name?: string; + content: string | string[]; + content_format?: 'locked' | 'encrypted' | 'html' | 'text' | 'json' | null; + content_scheme?: 'fragmented-v2' | 'fragmented-v1' | string | null; + remint_chapter_id?: number | null; + remint_chapter_uuid?: string | null; + locked?: { + price: number; + unlocked_at?: string | null; + is_read_only?: boolean; + } | null; + bought?: { + wallet_type?: string | null; + amount?: number; + bought_at?: string | null; + } | null; + bypass?: boolean; +}; + +type CachedReadToken = { + value: string; + expiresAt: number; + cid: number | null; +}; + type ChapterInfo = { name: string; path: string; @@ -75,9 +298,11 @@ class FenrirRealmPlugin implements Plugin.PluginBase { name = 'Fenrir Realm'; icon = 'src/en/fenrirrealm/icon.png'; site = 'https://fenrirealm.com'; - version = '1.1.3'; + version = '1.1.4'; imageRequestInit?: Plugin.ImageRequestInit | undefined = undefined; + private cachedReadToken: CachedReadToken | null = null; + hideLocked = storage.get('hideLocked'); pluginSettings = { hideLocked: { @@ -90,6 +315,295 @@ class FenrirRealmPlugin implements Plugin.PluginBase { //flag indicates whether access to LocalStorage, SesesionStorage is required. webStorageUtilized?: boolean; + private setCachedReadToken( + token: string, + expiresAt?: number | null, + cid?: number | null, + ): void { + if (!token) return; + const parsed = parseReadTokenPayload(token); + const resolvedExp = + typeof expiresAt === 'number' && expiresAt > 0 + ? expiresAt + : parsed.exp !== null + ? parsed.exp + : Math.floor(Date.now() / 1000) + 300; + const resolvedCid = + parsed.cid !== null + ? parsed.cid + : typeof cid === 'number' && cid > 0 + ? cid + : null; + this.cachedReadToken = { + value: token, + expiresAt: resolvedExp, + cid: resolvedCid, + }; + } + + private getActiveTokenValue(): string | null { + if (!this.cachedReadToken) return null; + if (this.cachedReadToken.expiresAt * 1000 - Date.now() < 5000) { + this.cachedReadToken = null; + return null; + } + return this.cachedReadToken.value; + } + + private getCachedReadToken(chapterId?: number | null): string | null { + const value = this.getActiveTokenValue(); + if (!value || !this.cachedReadToken) return null; + if ( + chapterId != null && + this.cachedReadToken.cid !== null && + this.cachedReadToken.cid !== chapterId + ) { + return null; + } + return value; + } + + private captureReadTokenHeader(res: Response, cid?: number | null): void { + const issued = res.headers?.get?.('x-read-token-issued'); + if (!issued) return; + const expHeader = res.headers?.get?.('x-read-token-expires-at'); + const parsedExp = expHeader ? parseInt(expHeader, 10) : NaN; + this.setCachedReadToken( + issued, + Number.isFinite(parsedExp) ? parsedExp : null, + cid, + ); + } + + private resolveRemintTarget( + data: APIChapterData, + fallbackId?: string, + ): string | number | null { + return ( + data.remint_chapter_uuid || + data.remint_chapter_id || + data.uuid || + data.id || + fallbackId || + null + ); + } + + private buildChapterHeaders(): Record { + const headers: Record = { + 'Accept': 'application/json', + 'X-Accepts-Encrypted-Content': '3', + }; + const activeToken = this.getActiveTokenValue(); + if (activeToken) { + headers['Cookie'] = `fr_read=${activeToken}`; + } + return headers; + } + + private async mintReadToken( + chapterTarget?: string | number | null, + cid?: number | null, + ): Promise { + try { + const payload: Record = {}; + if ( + chapterTarget !== null && + chapterTarget !== undefined && + chapterTarget !== '' + ) { + payload.chapter_id = String(chapterTarget); + } + const requestMint = async (cookieToken: string | null) => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (cookieToken) { + headers['Cookie'] = `fr_read=${cookieToken}`; + } + const res = await fetchApi(`${this.site}/api/read-token`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + if (!res.ok) return null; + const data = await res.json(); + return data?.active && + typeof data.token === 'string' && + data.token !== '' + ? data + : null; + }; + + const activeToken = this.getActiveTokenValue(); + let data = await requestMint(activeToken); + if (!data && activeToken) { + this.cachedReadToken = null; + data = await requestMint(null); + } + if (data) { + const numericId = + typeof cid === 'number' && cid > 0 + ? cid + : typeof chapterTarget === 'number' + ? chapterTarget + : null; + this.setCachedReadToken(data.token, data.expires_at, numericId); + return true; + } + this.cachedReadToken = null; + return false; + } catch { + return false; + } + } + + private async getReadToken( + chapterId?: number | null, + ): Promise { + const cached = this.getCachedReadToken(chapterId); + if (cached !== null) return cached; + try { + const headers: Record = { + 'Accept': 'application/json', + }; + const activeToken = this.getActiveTokenValue(); + if (activeToken) { + headers['Cookie'] = `fr_read=${activeToken}`; + } + const res = await fetchApi(`${this.site}/api/read-token`, { + method: 'GET', + headers, + }); + if (!res.ok) return null; + const data = await res.json(); + if (data?.active && typeof data.token === 'string' && data.token !== '') { + this.setCachedReadToken( + data.token, + typeof data.expires_at === 'number' ? data.expires_at : null, + chapterId ?? null, + ); + return data.token; + } + return null; + } catch { + return null; + } + } + + private decryptWithToken( + chapterData: APIChapterData, + token: string, + ): string | null { + try { + const isV2 = chapterData.content_scheme === 'fragmented-v2'; + const vid = isV2 ? extractVidFromReadToken(token) : ''; + const b64Cipher = Array.isArray(chapterData.content) + ? unshuffleFragments( + chapterData.content, + isV2 + ? `${token}:${vid}:${chapterData.id}:${FRAG_SALT_V2}` + : `${token}:${chapterData.id}:${FRAG_SALT_V1}`, + ) + : typeof chapterData.content === 'string' + ? chapterData.content + : ''; + if (!b64Cipher) return null; + + const raw = decodeBase64Bytes(b64Cipher); + if (raw.length <= AES_GCM_IV_LENGTH + AES_GCM_TAG_LENGTH) return null; + + const keySeed = + isV2 && vid !== '' + ? `${token}:${vid}:${chapterData.id}:${CONTENT_SALT_V2}` + : `${token}:${chapterData.id}:${CONTENT_SALT_V1}`; + const keyBytes = sha256Bytes(keySeed); + const iv = raw.slice(0, AES_GCM_IV_LENGTH); + const ciphertextWithTag = raw.slice(AES_GCM_IV_LENGTH); + const decrypted = gcm(keyBytes, iv).decrypt(ciphertextWithTag); + return new TextDecoder().decode(decrypted); + } catch { + return null; + } + } + + private formatChapterContent(content: string): string { + try { + // Attempt 1: Handle legacy stringified TipTap JSON AST schema + const parsedContent: Chapter = JSON.parse(content); + if ( + parsedContent.type === 'doc' || + parsedContent.type === 'systemWindow' + ) { + return parsedContent.content + .map(node => { + if (node.type === 'paragraph') { + const innerHtml = + node.content + ?.map(c => { + if (c.type === 'text') { + let text = c.text; + if (c.marks) { + for (const mark of c.marks) { + if (mark.type === 'bold') text = `${text}`; + if (mark.type === 'italic') text = `${text}`; + if (mark.type === 'underline') + text = `${text}`; + if (mark.type === 'strike') + text = `${text}`; + if (mark.type === 'link') + text = `${text}`; + } + } + return text; + } + if (c.type === 'hardBreak') return '
'; + return ''; + }) + .join('') || ''; + return `

${innerHtml}

`; + } + if (node.type === 'heading') { + const level = node.attrs?.level || 1; + const innerHtml = node.content?.map(c => c.text).join('') || ''; + return `${innerHtml}`; + } + return ''; + }) + .join('\n') + .replace(/[\u200B-\u200D\uFEFF]/g, ''); + } + } catch { + // Fall through to HTML cleaner + } + + /** + * HTML Response Cleaner: + * Fenrir Realm inserts anti-scraping & watermark elements: + * 1. Scramble hidden divs (`[aria-hidden="true"]`) + * 2. Inline `