106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import CryptoJS from "crypto-js";
|
|
import * as React from "react";
|
|
import * as jsxRuntime from "react/jsx-runtime";
|
|
import type { CryptoLockerModule } from "./types";
|
|
|
|
export type CryptoLockerStatus = "idle" | "loading" | "success" | "error";
|
|
|
|
/**
|
|
* Hook that provides logic for AES decryption of dynamic modules.
|
|
* UI, password handling, and validation are fully delegated to the developer.
|
|
*
|
|
* @param dependencies Optional dictionary of external dependencies to provide to the encrypted module.
|
|
*/
|
|
export function useCryptoLocker<T = unknown>(dependencies: Record<string, any> = {}) {
|
|
const [status, setStatus] = React.useState<CryptoLockerStatus>("idle");
|
|
const [decryptedData, setDecryptedData] = React.useState<T | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
/**
|
|
* Decrypts the dynamically loaded module and evaluates its code.
|
|
*
|
|
* @param fetchData A function that calls dynamic `import()` for the secret module.
|
|
* @param password The AES password.
|
|
*/
|
|
const unlock = useCallback(
|
|
async (
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
fetchData: () => Promise<any>,
|
|
password: string
|
|
): Promise<T> => {
|
|
setStatus("loading");
|
|
setError(null);
|
|
|
|
try {
|
|
const mod = (await fetchData()) as CryptoLockerModule;
|
|
const ciphertext = mod.encryptedData;
|
|
|
|
if (typeof ciphertext !== "string") {
|
|
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);
|
|
|
|
if (!plaintextCode) {
|
|
throw new Error("Invalid password or empty decryption result.");
|
|
}
|
|
|
|
// Evaluate the decrypted CommonJS code
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const exportsObj: any = {};
|
|
const moduleObj = { exports: exportsObj };
|
|
|
|
// Provide dependencies to the evaluated module
|
|
const requireFn = (id: string) => {
|
|
if (id === "react") return React;
|
|
if (id === "react/jsx-runtime") return jsxRuntime;
|
|
if (id === "react-dom") return (window as any).ReactDOM || {};
|
|
if (/\.(css|scss|less)$/i.test(id)) return {}; // Ignore CSS imports inside secret modules
|
|
if (id in dependencies) return dependencies[id];
|
|
throw new Error(`Cannot require '${id}' in encrypted module.`);
|
|
};
|
|
|
|
const fn = new Function(
|
|
"require",
|
|
"exports",
|
|
"module",
|
|
"React",
|
|
plaintextCode
|
|
);
|
|
fn(requireFn, exportsObj, moduleObj, React);
|
|
|
|
const parsed = (moduleObj.exports.default ?? moduleObj.exports) as T;
|
|
|
|
setDecryptedData(() => parsed);
|
|
setStatus("success");
|
|
return parsed;
|
|
} catch (err: unknown) {
|
|
console.error("CryptoLocker decryption failed:", err);
|
|
const errMsg = err instanceof Error ? err.message : "Decryption failed";
|
|
setError(errMsg);
|
|
setStatus("error");
|
|
setDecryptedData(null);
|
|
throw err;
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
/**
|
|
* Resets the hook state to idle, clearing any decrypted data and errors.
|
|
*/
|
|
const reset = useCallback(() => {
|
|
setStatus("idle");
|
|
setDecryptedData(null);
|
|
setError(null);
|
|
}, []);
|
|
|
|
return { unlock, reset, status, decryptedData, error };
|
|
}
|