diff --git a/scripts/pack-assets.ts b/scripts/pack-assets.ts new file mode 100644 index 0000000..09f7a44 --- /dev/null +++ b/scripts/pack-assets.ts @@ -0,0 +1,62 @@ +// scripts/pack-assets.ts +import { createCipheriv, randomBytes } from "crypto"; +import { globSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; + +const assetsDir = "src/assets"; +const outDir = "src/generated"; + +mkdirSync(outDir, { recursive: true }); + +const files = globSync(`${assetsDir}/**/*.*`); +const lines: string[] = []; + +// ── Runtime decryption preamble ────────────────────────────────── +// The generated file imports `createDecipheriv` once and declares +// a small helper that every export calls. Each key literal is +// wrapped in `scramble()` so the obfuscator can process it. +lines.push(`import { createDecipheriv } from "crypto";`); +lines.push(``); +lines.push(`declare function scramble(str: string): string;`); +lines.push(``); +lines.push(`function _dec(key: string, data: string): string {`); +lines.push(` const k = Buffer.from(key, "hex");`); +lines.push(` const buf = Buffer.from(data, "base64");`); +lines.push(` const iv = buf.subarray(0, 12);`); +lines.push(` const tag = buf.subarray(12, 28);`); +lines.push(` const ct = buf.subarray(28);`); +lines.push(` const dc = createDecipheriv("aes-256-gcm", k, iv);`); +lines.push(` dc.setAuthTag(tag);`); +lines.push(` const pt = Buffer.concat([dc.update(ct), dc.final()]);`); +lines.push(` return new TextDecoder().decode(Bun.gunzipSync(pt));`); +lines.push(`}`); +lines.push(``); + +// ── Encrypt and emit each asset ────────────────────────────────── +for (const file of files) { + const content = readFileSync(file); + const compressed = Bun.gzipSync(content); + const name = basename(file) + .replace(/\.[^.]+$/, "") + .replace(/[^a-zA-Z0-9]/g, "_"); + + // Per-file AES-256-GCM key (random 32 bytes / 256-bit). + const key = randomBytes(32); + const keyHex = key.toString("hex"); + + // Encrypt the gzipped payload. + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(compressed), cipher.final()]); + const authTag = cipher.getAuthTag(); // 16 bytes + + // Wire format: iv (12 B) || authTag (16 B) || ciphertext + const packed = Buffer.concat([iv, authTag, encrypted]); + const base64 = packed.toString("base64"); + + lines.push( + `export const ${name} = _dec(scramble("${keyHex}"), "${base64}");`, + ); +} + +writeFileSync(join(outDir, "index.ts"), lines.join("\n") + "\n"); diff --git a/scripts/scramble-shared.ts b/scripts/scramble-shared.ts new file mode 100644 index 0000000..0096ce9 --- /dev/null +++ b/scripts/scramble-shared.ts @@ -0,0 +1,137 @@ +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__"; + +/** + * Path (relative to the project root) of the runtime decoder source + * file whose passphrase placeholder gets rewritten per build. + */ +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"); +} + +/** + * 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, + 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)}...) -> beautify("${encoded.slice(0, 16)}...")${where}`, + ); + return `beautify(${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, +): Promise { + const original = await fs.readFile(decoderPath, "utf-8"); + + if (!original.includes(RUNTIME_PASSPHRASE_PLACEHOLDER)) { + throw new Error( + `[SCRAMBLE] Could not find passphrase placeholder ` + + `"${RUNTIME_PASSPHRASE_PLACEHOLDER}" in ${decoderPath}. ` + + `The runtime decoder must contain the sentinel string so the ` + + `build pipeline can inject the per-build passphrase.`, + ); + } + + // JSON.stringify gives us a safely-quoted JS string literal. + const literal = JSON.stringify(passphrase); + + // The placeholder appears inside an existing string literal, e.g. + // const PASSPHRASE = "__SCRAMBLE_BUILD_PASSPHRASE__"; + // We want to end up with: + // const PASSPHRASE = ""; + // so we replace the *quoted placeholder* (including its surrounding + // double-quotes) with the JSON-encoded passphrase literal. + const quotedPlaceholder = `"${RUNTIME_PASSPHRASE_PLACEHOLDER}"`; + if (!original.includes(quotedPlaceholder)) { + throw new Error( + `[SCRAMBLE] Found placeholder text but not the expected quoted ` + + `form ${quotedPlaceholder} in ${decoderPath}. The placeholder ` + + `must appear as a standalone double-quoted string literal.`, + ); + } + + return original.split(quotedPlaceholder).join(literal); +} diff --git a/scripts/strip-logs.ts b/scripts/strip-logs.ts new file mode 100644 index 0000000..600084f --- /dev/null +++ b/scripts/strip-logs.ts @@ -0,0 +1,120 @@ +/** + * Build-time transform that strips all `logUtil.(...)` call + * statements from source code so they are completely absent from the + * bundle — including argument evaluation. + * + * Uses balanced-paren counting with string/template-literal awareness + * so nested expressions like `logUtil.info(`batch ${arr.join(",")}`)` + * are handled correctly. + */ + +const LOG_CALL_START = /logUtil\.(log|info|warn|error)\s*\(/g; + +/** + * Advances past a string literal (single-quoted, double-quoted, or + * backtick template) starting at `pos`. Returns the index immediately + * after the closing quote. + */ +function skipString(code: string, pos: number): number { + const quote = code[pos]; // one of ' " ` + let i = pos + 1; + while (i < code.length) { + const ch = code[i]; + if (ch === "\\") { + i += 2; // skip escaped char + continue; + } + if (quote === "`" && ch === "$" && code[i + 1] === "{") { + // Template interpolation — skip into the expression and count + // braces so we resurface after the closing `}`. + i += 2; + let depth = 1; + while (i < code.length && depth > 0) { + const c = code[i]; + if (c === "{") depth++; + else if (c === "}") depth--; + else if (c === '"' || c === "'" || c === "`") { + i = skipString(code, i); + continue; + } else if (c === "\\") { + i += 2; + continue; + } + i++; + } + continue; + } + if (ch === quote) { + return i + 1; // past closing quote + } + i++; + } + return i; // unterminated — return end of file +} + +/** + * Starting right after the opening `(`, finds the index of the + * matching `)`. Returns -1 if unbalanced. + */ +function findClosingParen(code: string, start: number): number { + let depth = 1; + let i = start; + while (i < code.length && depth > 0) { + const ch = code[i]; + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) return i; + } else if (ch === '"' || ch === "'" || ch === "`") { + i = skipString(code, i); + continue; + } else if (ch === "\\") { + i += 2; + continue; + } + i++; + } + return -1; +} + +export function stripLogCalls( + code: string, + logPrefix = "[STRIP-LOGS]", + sourceLabel?: string, +): { code: string; stripped: number } { + let result = ""; + let lastIndex = 0; + let stripped = 0; + + let match: RegExpExecArray | null; + LOG_CALL_START.lastIndex = 0; + + while ((match = LOG_CALL_START.exec(code)) !== null) { + const callStart = match.index; + const afterOpenParen = match.index + match[0].length; + + const closeParen = findClosingParen(code, afterOpenParen); + if (closeParen === -1) break; // unbalanced — bail out safely + + // Consume the closing paren + let end = closeParen + 1; + + // Consume optional semicolon + trailing whitespace/newline + if (code[end] === ";") end++; + if (code[end] === "\n") end++; + + // Replace the entire statement with nothing + result += code.slice(lastIndex, callStart); + lastIndex = end; + stripped++; + } + + result += code.slice(lastIndex); + + if (stripped > 0) { + const where = sourceLabel ? ` in ${sourceLabel}` : ""; + console.log(`${logPrefix} Stripped ${stripped} logUtil call(s)${where}`); + } + + return { code: result, stripped }; +}