import { randomBytes } from "crypto"; import { promises as fs } from "fs"; import type { StringScrambler } from "../src/utils/stringtool"; /** * Sentinel string in `src/utils/runtimeDecoder.ts` that the build * pipelines rewrite with the freshly-generated passphrase for the * current build. * * Keep this in sync with the literal in `runtimeDecoder.ts`. */ export const RUNTIME_PASSPHRASE_PLACEHOLDER = "__SCRAMBLE_BUILD_PASSPHRASE__"; /** * Sentinel string for the per-build salt injected into the runtime * decoder. Same mechanism as the passphrase placeholder. */ export const RUNTIME_SALT_PLACEHOLDER = "__SCRAMBLE_BUILD_SALT__"; export const RUNTIME_FN_NAME_PLACEHOLDER = "__SCRAMBLE_FN_NAME__"; export const RUNTIME_DECODER_PATH = "src/utils/runtimeDecoder.ts"; /** * Regex used to find `scramble(...)` calls in source code. * * Accepts either a double-quoted or backtick-quoted single string * literal as the only argument. Single-quoted strings, concatenations, * and template interpolations are intentionally not supported — those * would not survive the textual transform safely. */ export const SCRAMBLE_CALL_REGEX = /scramble\(\s*(`[\s\S]*?`|"[\s\S]*?")\s*,?\s*\)/g; /** * Regex used to strip out `declare function scramble(...)` lines from * the transformed source. The runtime has no `scramble` symbol — only * `beautify` — so the declaration is dead weight at runtime. */ export const SCRAMBLE_DECLARE_REGEX = /declare\s+function\s+scramble[^;]*;\s*\n?/g; /** * Generates a fresh random passphrase to be used for this build. * * The passphrase is 64 hex characters (32 random bytes). It is meant to * be ephemeral: it is generated once per build, used to encode every * `scramble(...)` call site, and then baked into the runtime decoder so * that decoding works at runtime without any environment variables. */ export function generateBuildPassphrase(): string { return randomBytes(32).toString("hex"); } export function generateBuildSalt(): string { return randomBytes(16).toString("hex"); } export function generateFunctionName(): string { return "f" + randomBytes(4).toString("hex"); } /** * Transforms a single source file's text by replacing every * `scramble("...")` / `` scramble(`...`) `` call with a * `beautify("")` call encoded with the supplied * scrambler, and stripping out the matching `declare function scramble` * statements. * * The transform is purely textual; it makes no attempt to parse the * source. The constraints documented on `SCRAMBLE_CALL_REGEX` apply. * * @param code The original source code. * @param scrambler The `StringScrambler` to use for encoding. * @param logPrefix Optional log prefix for build output (e.g. "[BUILD]"). * @param sourceLabel Optional label (filename) included in log output. */ export function transformSource( code: string, scrambler: StringScrambler, fnName: string, logPrefix = "[SCRAMBLE]", sourceLabel?: string, ): { code: string; replacements: number } { let replacements = 0; const transformed = code.replace( SCRAMBLE_CALL_REGEX, (_match, str: string) => { const inner = str.slice(1, -1); const encoded = scrambler.encode(inner); replacements++; const where = sourceLabel ? ` in ${sourceLabel}` : ""; console.log( `${logPrefix} scramble(${str.slice(0, 32)}...) -> ${fnName}("${encoded.slice(0, 16)}...")${where}`, ); return `${fnName}(${JSON.stringify(encoded)})`; }, ); const stripped = transformed.replace(SCRAMBLE_DECLARE_REGEX, ""); return { code: stripped, replacements }; } /** * Reads the runtime decoder source, replaces the build-time placeholder * passphrase with the supplied real passphrase, and returns the new * contents. The original file on disk is NOT modified — callers are * expected to write the rewritten contents to a temp/output location. * * Throws if the placeholder cannot be found, which would otherwise * silently produce a bundle that decodes to garbage at runtime. */ export async function rewriteRuntimeDecoder( decoderPath: string, passphrase: string, salt: string, fnName: string, ): Promise { const original = await fs.readFile(decoderPath, "utf-8"); let code = original; for (const [placeholder, value] of [ [RUNTIME_PASSPHRASE_PLACEHOLDER, passphrase], [RUNTIME_SALT_PLACEHOLDER, salt], [RUNTIME_FN_NAME_PLACEHOLDER, fnName], ] as const) { if (!code.includes(placeholder)) { throw new Error( `[SCRAMBLE] Could not find placeholder "${placeholder}" in ${decoderPath}.`, ); } const quoted = `"${placeholder}"`; code = code.split(quoted).join(JSON.stringify(value)); } return code; }