feat: extend crypto-locker plugin to support automatic AES encryption for static assets via new useCryptoAsset hook
This commit is contained in:
Generated
+38
-3
@@ -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
@@ -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,4 +1,5 @@
|
||||
export { useCryptoLocker } from "./useCryptoLocker";
|
||||
export { useCryptoAsset } from "./useCryptoAsset";
|
||||
export type { CryptoLockerStatus } from "./useCryptoLocker";
|
||||
export type {
|
||||
CryptoLockerModule,
|
||||
|
||||
+139
-20
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user