Files
locker/core/src/useCryptoLocker.ts
T

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 };
}