diff --git a/core/package-lock.json b/core/package-lock.json index 97fe980..3f7eac1 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,17 +1,20 @@ { - "name": "@crypto-locker/core", + "name": "vite-plugin-component-locker", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@crypto-locker/core", + "name": "vite-plugin-component-locker", "version": "1.0.0", + "license": "MIT", "dependencies": { - "crypto-js": "^4.2.0" + "crypto-js": "^4.2.0", + "mime-types": "^3.0.2" }, "devDependencies": { "@types/crypto-js": "^4.2.2", + "@types/mime-types": "^3.0.1", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.5.2", @@ -1216,6 +1219,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1510,6 +1520,31 @@ "yallist": "^3.0.2" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/core/package.json b/core/package.json index 9de6f49..297f6d7 100644 --- a/core/package.json +++ b/core/package.json @@ -30,10 +30,12 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "crypto-js": "^4.2.0" + "crypto-js": "^4.2.0", + "mime-types": "^3.0.2" }, "devDependencies": { "@types/crypto-js": "^4.2.2", + "@types/mime-types": "^3.0.1", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.5.2", @@ -45,4 +47,4 @@ "scripts": { "build": "vite build" } -} \ No newline at end of file +} diff --git a/core/src/index.ts b/core/src/index.ts index 6eb418f..9249bd7 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,4 +1,5 @@ export { useCryptoLocker } from "./useCryptoLocker"; +export { useCryptoAsset } from "./useCryptoAsset"; export type { CryptoLockerStatus } from "./useCryptoLocker"; export type { CryptoLockerModule, diff --git a/core/src/plugin.ts b/core/src/plugin.ts index 5b6d955..51a0fe9 100644 --- a/core/src/plugin.ts +++ b/core/src/plugin.ts @@ -1,18 +1,13 @@ import type { Plugin } from "vite"; import type { CryptoLockerPluginOptions } from "./types"; +import path from "path"; +import fs from "fs/promises"; +import cryptoJs from "crypto-js"; -const DEFAULT_INCLUDE = /\.secret\.[jt]sx?$/; +const DEFAULT_INCLUDE = /\.secret\.[a-z0-9]+$/i; /** * Vite plugin for automatic AES encryption of modules during build/dev. - * - * Files matching the `include` pattern (default `*.secret.ts`), - * are transformed: their source code is compiled to CommonJS, - * encrypted via AES with the specified password, and replaced with: - * - * export const encryptedData = ""; - * - * The password never ends up in the build artifacts. */ export function cryptoLockerPlugin( options: CryptoLockerPluginOptions, @@ -33,22 +28,145 @@ export function cryptoLockerPlugin( ); } + let configOutputDest = "dist"; + return { name: "vite-plugin-crypto-locker", enforce: "pre", + configResolved(config) { + configOutputDest = config.build.outDir; + }, + + // 1. DEVELOPMENT: Intercept requests to public static assets + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url) return next(); + const url = req.url.split('?')[0]; + + // If it's a secret file request + if (pattern.test(url)) { + // Check if it exists in public dir + const publicPath = path.join(server.config.publicDir, url); + try { + const stat = await fs.stat(publicPath); + if (stat.isFile()) { + const buffer = await fs.readFile(publicPath); + const base64 = buffer.toString("base64"); + const encrypted = cryptoJs.AES.encrypt(base64, password).toString(); + + // Determine mime type + const mime = (await import("mime-types")).default; + const mimeType = mime.lookup(publicPath) || "application/octet-stream"; + + // Return JSON payload matching our asset interface + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + type: "asset", + encryptedData: encrypted, + mimeType + })); + return; + } + } catch (e) { + // File not found in public, let Vite handle it (might be a src/ module) + } + } + next(); + }); + }, + + // 2. BUILD: Post-process copied public files in dist folder + async closeBundle() { + async function walkAndEncrypt(dir: string) { + try { + const files = await fs.readdir(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = await fs.stat(fullPath); + + if (stat.isDirectory()) { + await walkAndEncrypt(fullPath); + } else if (pattern.test(file)) { + // It's a secret file copied from public + const buffer = await fs.readFile(fullPath); + + // Skip if already looks like our JSON payload + try { + const text = buffer.toString('utf-8'); + if (text.includes('"type":"asset"') && text.includes('"encryptedData"')) { + continue; + } + } catch(e) {} + + const base64 = buffer.toString("base64"); + const encrypted = cryptoJs.AES.encrypt(base64, password).toString(); + + const mime = (await import("mime-types")).default; + const mimeType = mime.lookup(fullPath) || "application/octet-stream"; + + const payload = JSON.stringify({ + type: "asset", + encryptedData: encrypted, + mimeType + }); + + // Overwrite the file in dist with the encrypted JSON payload + await fs.writeFile(fullPath, payload, "utf-8"); + console.log(`[crypto-locker] Encrypted public asset: ${fullPath}`); + } + } + } catch (e) { + // Ignore if dist doesn't exist + } + } + + const outDir = path.resolve(process.cwd(), configOutputDest); + await walkAndEncrypt(outDir); + }, + + // 3. MODULE GRAPH: Handle imports from src/ + async load(id: string) { + const cleanId = id.split('?')[0]; + if (!pattern.test(cleanId)) return null; + + const isAsset = !/\.[jt]sx?$/i.test(cleanId); + + if (isAsset) { + const mime = (await import("mime-types")).default; + + try { + const buffer = await fs.readFile(cleanId); + const base64 = buffer.toString("base64"); + const mimeType = mime.lookup(cleanId) || "application/octet-stream"; + + const encrypted = cryptoJs.AES.encrypt(base64, password).toString(); + + return ` +export const type = "asset"; +export const encryptedData = ${JSON.stringify(encrypted)}; +export const mimeType = ${JSON.stringify(mimeType)}; + `; + } catch (err) { + console.error(`[crypto-locker] Failed to encrypt asset ${cleanId}:`, err); + return null; + } + } + + return null; + }, + async transform(code: string, id: string) { - // Ignore files that do not match the pattern - if (!pattern.test(id)) return null; + const cleanId = id.split('?')[0]; + if (!pattern.test(cleanId)) return null; + + const isAsset = !/\.[jt]sx?$/i.test(cleanId); + if (isAsset) return null; // Handled in load() - // Dynamic import of dependencies (available via Vite/Node) const { transform: esbuildTransform } = await import("esbuild"); - const CryptoJS = (await import("crypto-js")).default; - // 1. Compile TS/TSX → CJS JS via esbuild - // Use "transform" for JSX to avoid imports to "react/jsx-runtime" const { code: jsCode } = await esbuildTransform(code, { - loader: id.match(/\.tsx$/) ? "tsx" : "ts", + loader: cleanId.match(/\.tsx$/i) ? "tsx" : "ts", format: "cjs", target: "es2020", jsx: "transform", @@ -56,12 +174,13 @@ export function cryptoLockerPlugin( jsxFragment: "React.Fragment" }); - // 2. Encrypt the compiled JavaScript code - const encrypted = CryptoJS.AES.encrypt(jsCode, password).toString(); + const encrypted = cryptoJs.AES.encrypt(jsCode, password).toString(); - // 3. Return the transformed module return { - code: `export const encryptedData = ${JSON.stringify(encrypted)};\n`, + code: ` +export const type = "component"; +export const encryptedData = ${JSON.stringify(encrypted)}; + `, map: null, }; }, diff --git a/core/src/types.ts b/core/src/types.ts index 199ae06..141537d 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -1,10 +1,18 @@ -import type { ReactNode } from "react"; +export type CryptoModuleType = "component" | "asset"; -/** Module transformed by the Vite plugin (contains the encrypted string). */ -export interface CryptoLockerModule { +export interface CryptoLockerComponentModule { + type: "component"; encryptedData: string; } +export interface CryptoLockerAssetModule { + type: "asset"; + encryptedData: string; + mimeType: string; +} + +/** Module transformed by the Vite plugin */ +export type CryptoLockerModule = CryptoLockerComponentModule | CryptoLockerAssetModule; /** Vite plugin options. */ export interface CryptoLockerPluginOptions { @@ -13,7 +21,7 @@ export interface CryptoLockerPluginOptions { /** * File pattern to encrypt. - * @default /\.secret\.[jt]sx?$/ + * @default /\.secret\.[jt]sx?$/i */ include?: string | RegExp; } diff --git a/core/src/useCryptoAsset.ts b/core/src/useCryptoAsset.ts new file mode 100644 index 0000000..39e3c7a --- /dev/null +++ b/core/src/useCryptoAsset.ts @@ -0,0 +1,112 @@ +import { useState, useCallback, useEffect } from "react"; +import CryptoJS from "crypto-js"; +import type { CryptoLockerStatus } from "./useCryptoLocker"; +import type { CryptoLockerModule } from "./types"; + +// Base64 to Uint8Array helper +function base64ToUint8Array(base64: string): Uint8Array { + const binaryString = window.atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +/** + * Hook that provides logic for AES decryption of static assets (images, PDFs, etc.). + * UI, password handling, and validation are fully delegated to the developer. + */ +export function useCryptoAsset() { + const [status, setStatus] = useState("idle"); + const [decryptedUrl, setDecryptedUrl] = useState(null); + const [error, setError] = useState(null); + + // Clean up the Object URL when the component unmounts or URL changes + useEffect(() => { + return () => { + if (decryptedUrl) { + URL.revokeObjectURL(decryptedUrl); + } + }; + }, [decryptedUrl]); + + /** + * Fetches and decrypts the secret asset from a URL. + * + * @param source A URL string (for public assets) or a dynamic import function (for src/ assets) + * @param password The AES password. + */ + const unlock = useCallback( + async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + source: string | (() => Promise), + password: string + ): Promise => { + setStatus("loading"); + setError(null); + + try { + let mod: CryptoLockerModule; + + if (typeof source === "string") { + // Fetch from URL + const res = await fetch(source); + if (!res.ok) { + throw new Error(`Failed to fetch asset: ${res.statusText}`); + } + mod = await res.json(); + } else { + // Dynamic import + mod = (await source()) as CryptoLockerModule; + } + + if (mod.type !== "asset") { + throw new Error("This module is not a static asset. Use useCryptoLocker instead."); + } + + const ciphertext = mod.encryptedData; + if (typeof ciphertext !== "string") { + throw new Error("Payload does not contain 'encryptedData'."); + } + + // Decrypt AES + const bytes = CryptoJS.AES.decrypt(ciphertext, password); + const plaintextBase64 = bytes.toString(CryptoJS.enc.Utf8); + + if (!plaintextBase64) { + throw new Error("Invalid password or empty decryption result."); + } + + // Convert base64 back to Blob + const uint8Array = base64ToUint8Array(plaintextBase64); + const blob = new Blob([uint8Array], { type: mod.mimeType || "application/octet-stream" }); + const objectUrl = URL.createObjectURL(blob); + + setDecryptedUrl(objectUrl); + setStatus("success"); + return objectUrl; + } catch (err: unknown) { + console.error("CryptoLocker decryption failed:", err); + const errMsg = err instanceof Error ? err.message : "Decryption failed"; + setError(errMsg); + setStatus("error"); + setDecryptedUrl(null); + throw err; + } + }, + [] + ); + + /** + * Resets the hook state to idle, clearing any decrypted data and errors. + */ + const reset = useCallback(() => { + setStatus("idle"); + setDecryptedUrl(null); + setError(null); + }, []); + + return { unlock, reset, status, decryptedUrl, error }; +} diff --git a/core/src/useCryptoLocker.ts b/core/src/useCryptoLocker.ts index 06956ea..26d537e 100644 --- a/core/src/useCryptoLocker.ts +++ b/core/src/useCryptoLocker.ts @@ -37,6 +37,10 @@ export function useCryptoLocker() { throw new Error("Module does not export 'encryptedData'."); } + if (mod.type !== "component") { + throw new Error("Expected a 'component' module, but received: " + mod.type); + } + const bytes = CryptoJS.AES.decrypt(ciphertext, password); const plaintextCode = bytes.toString(CryptoJS.enc.Utf8);