feat: extend crypto-locker plugin to support automatic AES encryption for static assets via new useCryptoAsset hook

This commit is contained in:
Vitalii Litvinchuk
2026-06-10 20:12:05 +03:00
parent d83b82a5c6
commit 8fa7846da0
7 changed files with 310 additions and 29 deletions
+38 -3
View File
@@ -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",
+3 -1
View File
@@ -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",
+1
View File
@@ -1,4 +1,5 @@
export { useCryptoLocker } from "./useCryptoLocker";
export { useCryptoAsset } from "./useCryptoAsset";
export type { CryptoLockerStatus } from "./useCryptoLocker";
export type {
CryptoLockerModule,
+139 -20
View File
@@ -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 = "<ciphertext>";
*
* 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,
};
},
+12 -4
View File
@@ -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;
}
+112
View File
@@ -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<CryptoLockerStatus>("idle");
const [decryptedUrl, setDecryptedUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<any>),
password: string
): Promise<string> => {
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 };
}
+4
View File
@@ -37,6 +37,10 @@ export function useCryptoLocker<T = unknown>() {
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);