refactor(utils): 重构加密文件处理工具模块

将加密文件处理相关功能拆分为独立工具模块,包括文件工具、上传工具、图片加载工具和加密工具

重构 cryptioSaveImage.ts 和 cryptioUploadImage.ts,使用新的工具模块替换原有代码
This commit is contained in:
Suzu008
2025-10-12 06:34:57 +08:00
parent 790907bdf6
commit 05ac7e0542
7 changed files with 666 additions and 701 deletions
+9 -202
View File
@@ -8,190 +8,11 @@ import type {
ComfyNodeDef,
} from "@comfyorg/comfyui-frontend-types";
import { loadEncryptedImageFromParams, downloadDecryptedImage } from "./utils/imageLoader.js";
const app: ComfyApp = rawApp;
const api: ComfyApi = rawApi;
// 密钥存储键名
const SERVER_KEYS_STORAGE_KEY = "cryptio_server_keys";
// 密钥接口
interface ServerKeys {
publicKey: string;
privateKey: string;
}
// 从PEM导入私钥
async function importPrivateKeyFromPem(pem: string): Promise<CryptoKey> {
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem
.substring(
pem.indexOf(pemHeader) + pemHeader.length,
pem.indexOf(pemFooter)
)
.replace(/\s/g, "");
const binaryDer = window.atob(pemContents);
const binaryDerArray = new Uint8Array(binaryDer.length);
for (let i = 0; i < binaryDer.length; i++) {
binaryDerArray[i] = binaryDer.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
"pkcs8",
binaryDerArray,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["decrypt"]
);
}
// 获取服务端密钥
async function getServerKeys(): Promise<ServerKeys> {
const cached = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
console.error("Failed to parse stored server keys:", e);
}
}
throw new Error("Server keys not found. Please upload an image first to exchange keys.");
}
// 解密文件(用于预览)
async function decryptFile(encryptedData: Uint8Array): Promise<Uint8Array> {
const serverKeys = await getServerKeys();
// 解析组合数据
let offset = 0;
// 1. 读取加密的AES密钥长度
const view = new DataView(encryptedData.buffer);
const encryptedAesKeyLength = view.getUint32(offset, false);
offset += 4;
// 2. 读取加密的AES密钥
const encryptedAesKey = encryptedData.slice(
offset,
offset + encryptedAesKeyLength
);
offset += encryptedAesKeyLength;
// 3. 读取IV长度
const ivLength = view.getUint32(offset, false);
offset += 4;
// 4. 读取IV
const iv = encryptedData.slice(offset, offset + ivLength);
offset += ivLength;
// 5. 读取加密的数据
const encrypted = encryptedData.slice(offset);
// 6. 使用服务端私钥解密AES密钥
const serverPrivateKey = await importPrivateKeyFromPem(
serverKeys.privateKey
);
const aesKeyBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
serverPrivateKey,
encryptedAesKey
);
// 7. 导入AES密钥
const aesKey = await window.crypto.subtle.importKey(
"raw",
aesKeyBuffer,
{
name: "AES-GCM",
length: 256,
},
false,
["decrypt"]
);
// 8. 使用AES密钥解密数据
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
encrypted
);
return new Uint8Array(decryptedBuffer);
}
// 从文件名中提取MIME类型
function getMimeTypeFromFilename(filename: string): string {
const baseFilename = filename.replace(/\.encrypted$/, '');
// 提取原始扩展名
const lastDotIndex = baseFilename.lastIndexOf('.');
if (lastDotIndex === -1) {
// 如果没有扩展名,默认返回image/png
return 'image/png';
}
const extension = baseFilename.substring(lastDotIndex + 1).toLowerCase();
// 扩展名到MIME类型的映射
const mimeTypes: { [key: string]: string } = {
// 图片格式
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'tiff': 'image/tiff',
'tif': 'image/tiff',
};
return mimeTypes[extension] || 'image/png'; // 默认返回image/png
}
// 加载并解密图片用于预览
async function loadEncryptedImageForPreview(params: {
filename: string;
subfolder?: string;
type: string;
}): Promise<string> {
// 构建URL
const url = api.apiURL(
`/view?filename=${encodeURIComponent(params.filename)}&type=${params.type}${params.subfolder ? `&subfolder=${encodeURIComponent(params.subfolder)}` : ""
}`
);
// 从服务器获取加密数据(二进制格式)
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load encrypted image: ${response.statusText}`);
}
// 直接读取二进制数据
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 解密数据
const decryptedData = await decryptFile(encryptedData);
// 转换为Blob URL
const blob = new Blob([new Uint8Array(decryptedData)], {
type: getMimeTypeFromFilename(params.filename),
});
return URL.createObjectURL(blob);
}
// 注册扩展
app.registerExtension({
name: "cryptio.SaveImageCryptIO",
@@ -211,13 +32,13 @@ app.registerExtension({
// 处理加密图片
if (message?.cryptio_images) {
const autoDownload = this.widgets.find((n: any) => n.name === "auto_download").value
const autoDownload = this.widgets.find((n: any) => n.name === "auto_download")?.value;
for (let i = 0; i < message.cryptio_images.length; i++) {
const imageInfo = message.cryptio_images[i];
if (imageInfo.filename && imageInfo.filename.endsWith(".encrypted")) {
// 异步加载并解密图片
loadEncryptedImageForPreview(imageInfo)
loadEncryptedImageFromParams(api, imageInfo)
.then((imageUrl) => {
// 更新节点的图片显示
const img = new Image();
@@ -230,16 +51,8 @@ app.registerExtension({
// 如果开启了自动下载
if (autoDownload) {
// 创建下载链接
const a = document.createElement("a");
a.href = imageUrl;
// 移除 .encrypted 后缀
const originalFilename = imageInfo.filename.replace(/\.encrypted$/, "");
a.download = originalFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
console.log(`Auto-downloaded decrypted image: ${originalFilename}`);
downloadDecryptedImage(imageUrl, imageInfo.filename);
console.log(`Auto-downloaded decrypted image: ${imageInfo.filename.replace(/\.encrypted$/, "")}`);
}
};
img.src = imageUrl;
@@ -267,15 +80,9 @@ app.registerExtension({
for (let i = 0; i < this.imgs.length; i++) {
const img = this.imgs[i];
if (img && img.src) {
// 创建下载链接
const a = document.createElement("a");
a.href = img.src;
// 移除 .encrypted 后缀
const originalFilename = this.images?.[i]?.filename?.replace(/\.encrypted$/, "") || `image_${i}.png`;
a.download = originalFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 获取原始文件名
const filename = this.images?.[i]?.filename || `image_${i}.png.encrypted`;
downloadDecryptedImage(img.src, filename);
}
}
},
+7 -499
View File
@@ -8,486 +8,12 @@ import type {
ComfyNodeDef,
} from "@comfyorg/comfyui-frontend-types";
import { exchangeKeys } from "./utils/cryptoKeys.js";
import { handleFileUpload, createPreviewUpdateFunction } from "./utils/uploadUtils.js";
const app: ComfyApp = rawApp;
const api: ComfyApi = rawApi;
// 密钥存储键名
const CLIENT_KEYPAIR_STORAGE_KEY = "cryptio_client_keypair";
const SERVER_KEYS_STORAGE_KEY = "cryptio_server_keys";
// 密钥接口
interface KeyPair {
publicKey: string;
privateKey: string;
}
interface ServerKeys {
publicKey: string;
privateKey: string;
}
// 生成客户端密钥对
async function generateClientKeyPair(): Promise<KeyPair> {
// 生成RSA密钥对
const keyPair = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"]
);
// 导出公钥为PEM格式
const publicKeyBuffer = await window.crypto.subtle.exportKey(
"spki",
keyPair.publicKey
);
const publicKeyBase64 = btoa(
String.fromCharCode(...new Uint8Array(publicKeyBuffer))
);
const publicKeyPem =
"-----BEGIN PUBLIC KEY-----\n" +
publicKeyBase64.match(/.{1,64}/g)!.join("\n") +
"\n-----END PUBLIC KEY-----";
// 导出私钥为PEM格式
const privateKeyBuffer = await window.crypto.subtle.exportKey(
"pkcs8",
keyPair.privateKey
);
const privateKeyBase64 = btoa(
String.fromCharCode(...new Uint8Array(privateKeyBuffer))
);
const privateKeyPem =
"-----BEGIN PRIVATE KEY-----\n" +
privateKeyBase64.match(/.{1,64}/g)!.join("\n") +
"\n-----END PRIVATE KEY-----";
return {
publicKey: publicKeyPem,
privateKey: privateKeyPem,
};
}
// 获取或生成客户端密钥对
async function getClientKeyPair(): Promise<KeyPair> {
// 从localStorage获取
const stored = localStorage.getItem(CLIENT_KEYPAIR_STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error("Failed to parse stored client keypair:", e);
}
}
// 生成新的密钥对
console.log("Generating new client keypair...");
const keyPair = await generateClientKeyPair();
// 保存到localStorage
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
return keyPair;
}
// 从PEM导入公钥
async function importPublicKeyFromPem(pem: string): Promise<CryptoKey> {
const pemHeader = "-----BEGIN PUBLIC KEY-----";
const pemFooter = "-----END PUBLIC KEY-----";
const pemContents = pem
.substring(
pem.indexOf(pemHeader) + pemHeader.length,
pem.indexOf(pemFooter)
)
.replace(/\s/g, "");
const binaryDer = window.atob(pemContents);
const binaryDerArray = new Uint8Array(binaryDer.length);
for (let i = 0; i < binaryDer.length; i++) {
binaryDerArray[i] = binaryDer.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
"spki",
binaryDerArray,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["encrypt"]
);
}
// 从PEM导入私钥
async function importPrivateKeyFromPem(pem: string): Promise<CryptoKey> {
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem
.substring(
pem.indexOf(pemHeader) + pemHeader.length,
pem.indexOf(pemFooter)
)
.replace(/\s/g, "");
const binaryDer = window.atob(pemContents);
const binaryDerArray = new Uint8Array(binaryDer.length);
for (let i = 0; i < binaryDer.length; i++) {
binaryDerArray[i] = binaryDer.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
"pkcs8",
binaryDerArray,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["decrypt"]
);
}
// 与服务器交换密钥
async function exchangeKeys(): Promise<ServerKeys> {
// 检查缓存
const cached = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
console.error("Failed to parse stored server keys:", e);
}
}
// 获取客户端密钥对
const clientKeyPair = await getClientKeyPair();
// 发送客户端公钥到服务器
const response = await fetch("/cryptio/exchange_keys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_public_key: clientKeyPair.publicKey,
}),
});
if (!response.ok) {
throw new Error(`Key exchange failed: ${response.statusText}`);
}
const data = await response.json();
// 使用客户端私钥解密服务端密钥
const clientPrivateKey = await importPrivateKeyFromPem(
clientKeyPair.privateKey
);
// 1. 解密AES密钥
const encryptedAesKey = Uint8Array.from(
atob(data.encrypted_aes_key),
(c) => c.charCodeAt(0)
);
const aesKeyBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
clientPrivateKey,
encryptedAesKey
);
// 2. 导入AES密钥
const aesKey = await window.crypto.subtle.importKey(
"raw",
aesKeyBuffer,
{
name: "AES-CBC",
length: 256,
},
false,
["decrypt"]
);
// 3. 解密服务端密钥数据
const iv = Uint8Array.from(atob(data.iv), (c) => c.charCodeAt(0));
const encryptedData = Uint8Array.from(atob(data.encrypted_data), (c) =>
c.charCodeAt(0)
);
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
aesKey,
encryptedData
);
// 4. 解析JSON
const decryptedText = new TextDecoder().decode(decryptedBuffer);
const rawServerKeys = JSON.parse(decryptedText);
const serverKeys = {
publicKey: rawServerKeys.server_public_key,
privateKey: rawServerKeys.server_private_key,
};
// 5. 保存到localStorage
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(serverKeys));
return serverKeys;
}
// 获取服务端密钥
async function getServerKeys(): Promise<ServerKeys> {
return await exchangeKeys();
}
// 使用混合加密方案加密文件
async function encryptFile(file: File): Promise<Uint8Array> {
const serverKeys = await getServerKeys();
// 读取文件内容
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
// 1. 生成随机 AES 密钥
const aesKey = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt"]
);
// 2. 生成随机 IV
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// 3. 使用 AES 加密文件数据
const encryptedData = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
data
);
// 4. 导出 AES 密钥
const rawAesKey = await window.crypto.subtle.exportKey("raw", aesKey);
// 5. 使用服务端公钥加密 AES 密钥
const serverPublicKey = await importPublicKeyFromPem(
serverKeys.publicKey
);
const encryptedAesKey = await window.crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
serverPublicKey,
rawAesKey
);
// 6. 组合数据
const encryptedAesKeyArray = new Uint8Array(encryptedAesKey);
const encryptedDataArray = new Uint8Array(encryptedData);
const combined = new Uint8Array(
4 + encryptedAesKeyArray.length + 4 + iv.length + encryptedDataArray.length
);
const view = new DataView(combined.buffer);
view.setUint32(0, encryptedAesKeyArray.length, false);
combined.set(encryptedAesKeyArray, 4);
view.setUint32(4 + encryptedAesKeyArray.length, iv.length, false);
combined.set(iv, 4 + encryptedAesKeyArray.length + 4);
combined.set(encryptedDataArray, 4 + encryptedAesKeyArray.length + 4 + iv.length);
return combined;
}
// 解密文件(用于预览)
async function decryptFile(encryptedData: Uint8Array): Promise<Uint8Array> {
const serverKeys = await getServerKeys();
// 解析组合数据
let offset = 0;
// 1. 读取加密的AES密钥长度
const view = new DataView(encryptedData.buffer);
const encryptedAesKeyLength = view.getUint32(offset, false);
offset += 4;
// 2. 读取加密的AES密钥
const encryptedAesKey = encryptedData.slice(
offset,
offset + encryptedAesKeyLength
);
offset += encryptedAesKeyLength;
// 3. 读取IV长度
const ivLength = view.getUint32(offset, false);
offset += 4;
// 4. 读取IV
const iv = encryptedData.slice(offset, offset + ivLength);
offset += ivLength;
// 5. 读取加密的数据
const encrypted = encryptedData.slice(offset);
// 6. 使用服务端私钥解密AES密钥
const serverPrivateKey = await importPrivateKeyFromPem(
serverKeys.privateKey
);
const aesKeyBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
serverPrivateKey,
encryptedAesKey
);
// 7. 导入AES密钥
const aesKey = await window.crypto.subtle.importKey(
"raw",
aesKeyBuffer,
{
name: "AES-GCM",
length: 256,
},
false,
["decrypt"]
);
// 8. 使用AES密钥解密数据
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
encrypted
);
return new Uint8Array(decryptedBuffer);
}
// 自定义上传函数
async function uploadEncryptedImage(file: File): Promise<any> {
console.log("Encrypting and uploading image:", file.name);
// 加密文件
const encryptedData = file?.name?.endsWith(".encrypted") ? new Uint8Array(await file.arrayBuffer()) : await encryptFile(file);
// 创建FormData
const formData = new FormData();
const encryptedBlob = new Blob([new Uint8Array(encryptedData)], {
type: "application/octet-stream",
});
formData.append("image", encryptedBlob, file.name);
// 上传到服务器
const response = await fetch("/cryptio/upload_encrypted", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
return await response.json();
}
// 从文件名中提取MIME类型
function getMimeTypeFromFilename(filename: string): string {
const baseFilename = filename.replace(/\.encrypted$/, '');
// 提取原始扩展名
const lastDotIndex = baseFilename.lastIndexOf('.');
if (lastDotIndex === -1) {
// 如果没有扩展名,默认返回image/png
return 'image/png';
}
const extension = baseFilename.substring(lastDotIndex + 1).toLowerCase();
// 扩展名到MIME类型的映射
const mimeTypes: { [key: string]: string } = {
// 图片格式
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'tiff': 'image/tiff',
'tif': 'image/tiff',
// 视频格式
'mp4': 'video/mp4',
'webm': 'video/webm',
'mkv': 'video/x-matroska'
};
return mimeTypes[extension] || 'image/png'; // 默认返回image/png
}
// 加载并解密图片用于预览
async function loadEncryptedImageForPreview(filename: string): Promise<string> {
// 从服务器获取加密数据(二进制格式)
const response = await fetch(
`/cryptio/view_encrypted?filename=${encodeURIComponent(filename)}`
);
if (!response.ok) {
throw new Error(`Failed to load encrypted image: ${response.statusText}`);
}
// 直接读取二进制数据
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 解密数据
const decryptedData = await decryptFile(encryptedData);
// 转换为Blob URL
const blob = new Blob([new Uint8Array(decryptedData)], {
type: getMimeTypeFromFilename(filename),
});
return URL.createObjectURL(blob);
}
// 处理文件上传的通用方法
async function handleFileUpload(node: any, file: File) {
try {
// 上传加密图片
const result = await uploadEncryptedImage(file);
// 更新image widget的值
const imageWidget = node.widgets.find(
(w: any) => w.name === "image"
);
if (imageWidget) {
imageWidget.value = result.name;
}
// 更新预览
await node.updatePreview(result.name);
app.rootGraph?.setDirtyCanvas(true, false);
} catch (error) {
console.error("Upload error:", error);
alert("Failed to upload encrypted image: " + error);
}
}
// 注册扩展
app.registerExtension({
name: "cryptio.UploadImageCryptIO",
@@ -528,7 +54,7 @@ app.registerExtension({
fileInput.onchange = async () => {
if (fileInput.files && fileInput.files.length > 0) {
const file = fileInput.files[0];
await handleFileUpload(this, file);
await handleFileUpload(this, file, app);
}
document.body.removeChild(fileInput);
};
@@ -539,25 +65,7 @@ app.registerExtension({
uploadWidget.label = "choose file to upload";
// 添加预览更新方法
this.updatePreview = async function (filename: string) {
if (!filename || !filename.endsWith(".encrypted")) {
return;
}
try {
const imageUrl = await loadEncryptedImageForPreview(filename);
// 更新节点的图片显示
const img = new Image();
img.onload = () => {
this.imgs = [img];
app.rootGraph?.setDirtyCanvas(true, false);
};
img.src = imageUrl;
} catch (error) {
console.error("Preview error:", error);
}
};
this.updatePreview = createPreviewUpdateFunction(app);
// 当image值改变时更新预览
const imageWidget = this.widgets.find((w: any) => w.name === "image");
@@ -607,7 +115,7 @@ app.registerExtension({
}
// 上传文件
await handleFileUpload(this, file);
await handleFileUpload(this, file, app);
return true;
};
@@ -620,7 +128,7 @@ app.registerExtension({
continue
}
// 上传文件
await handleFileUpload(this, file);
await handleFileUpload(this, file, app);
return true;
}
+272
View File
@@ -0,0 +1,272 @@
/**
* 密钥管理模块
* 负责客户端和服务端密钥的生成、导入、交换和存储
*/
// 密钥存储键名
export const CLIENT_KEYPAIR_STORAGE_KEY = "cryptio_client_keypair";
export const SERVER_KEYS_STORAGE_KEY = "cryptio_server_keys";
// 密钥接口
export interface KeyPair {
publicKey: string;
privateKey: string;
}
export interface ServerKeys {
publicKey: string;
privateKey: string;
}
/**
* 生成客户端RSA密钥对
*/
export async function generateClientKeyPair(): Promise<KeyPair> {
// 生成RSA密钥对
const keyPair = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"]
);
// 导出公钥为PEM格式
const publicKeyBuffer = await window.crypto.subtle.exportKey(
"spki",
keyPair.publicKey
);
const publicKeyBase64 = btoa(
String.fromCharCode(...new Uint8Array(publicKeyBuffer))
);
const publicKeyPem =
"-----BEGIN PUBLIC KEY-----\n" +
publicKeyBase64.match(/.{1,64}/g)!.join("\n") +
"\n-----END PUBLIC KEY-----";
// 导出私钥为PEM格式
const privateKeyBuffer = await window.crypto.subtle.exportKey(
"pkcs8",
keyPair.privateKey
);
const privateKeyBase64 = btoa(
String.fromCharCode(...new Uint8Array(privateKeyBuffer))
);
const privateKeyPem =
"-----BEGIN PRIVATE KEY-----\n" +
privateKeyBase64.match(/.{1,64}/g)!.join("\n") +
"\n-----END PRIVATE KEY-----";
return {
publicKey: publicKeyPem,
privateKey: privateKeyPem,
};
}
/**
* 获取或生成客户端密钥对
*/
export async function getClientKeyPair(): Promise<KeyPair> {
// 从localStorage获取
const stored = localStorage.getItem(CLIENT_KEYPAIR_STORAGE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error("Failed to parse stored client keypair:", e);
}
}
// 生成新的密钥对
console.log("Generating new client keypair...");
const keyPair = await generateClientKeyPair();
// 保存到localStorage
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
return keyPair;
}
/**
* 从PEM格式导入公钥
*/
export async function importPublicKeyFromPem(pem: string): Promise<CryptoKey> {
const pemHeader = "-----BEGIN PUBLIC KEY-----";
const pemFooter = "-----END PUBLIC KEY-----";
const pemContents = pem
.substring(
pem.indexOf(pemHeader) + pemHeader.length,
pem.indexOf(pemFooter)
)
.replace(/\s/g, "");
const binaryDer = window.atob(pemContents);
const binaryDerArray = new Uint8Array(binaryDer.length);
for (let i = 0; i < binaryDer.length; i++) {
binaryDerArray[i] = binaryDer.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
"spki",
binaryDerArray,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["encrypt"]
);
}
/**
* 从PEM格式导入私钥
*/
export async function importPrivateKeyFromPem(pem: string): Promise<CryptoKey> {
const pemHeader = "-----BEGIN PRIVATE KEY-----";
const pemFooter = "-----END PRIVATE KEY-----";
const pemContents = pem
.substring(
pem.indexOf(pemHeader) + pemHeader.length,
pem.indexOf(pemFooter)
)
.replace(/\s/g, "");
const binaryDer = window.atob(pemContents);
const binaryDerArray = new Uint8Array(binaryDer.length);
for (let i = 0; i < binaryDer.length; i++) {
binaryDerArray[i] = binaryDer.charCodeAt(i);
}
return await window.crypto.subtle.importKey(
"pkcs8",
binaryDerArray,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
false,
["decrypt"]
);
}
/**
* 与服务器交换密钥,使用客户端公钥加密 AES 密钥
*/
export async function exchangeKeys(): Promise<ServerKeys> {
// 检查缓存
const cached = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
console.error("Failed to parse stored server keys:", e);
}
}
// 获取客户端密钥对
const clientKeyPair = await getClientKeyPair();
// 发送客户端公钥到服务器
const response = await fetch("/cryptio/exchange_keys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_public_key: clientKeyPair.publicKey,
}),
});
if (!response.ok) {
throw new Error(`Key exchange failed: ${response.statusText}`);
}
const data = await response.json();
// 使用客户端私钥解密服务端密钥
const clientPrivateKey = await importPrivateKeyFromPem(
clientKeyPair.privateKey
);
// 1. 解密AES密钥
const encryptedAesKey = Uint8Array.from(
atob(data.encrypted_aes_key),
(c) => c.charCodeAt(0)
);
const aesKeyBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
clientPrivateKey,
encryptedAesKey
);
// 2. 导入AES密钥
const aesKey = await window.crypto.subtle.importKey(
"raw",
aesKeyBuffer,
{
name: "AES-CBC",
length: 256,
},
false,
["decrypt"]
);
// 3. 解密服务端密钥数据
const iv = Uint8Array.from(atob(data.iv), (c) => c.charCodeAt(0));
const encryptedData = Uint8Array.from(atob(data.encrypted_data), (c) =>
c.charCodeAt(0)
);
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
aesKey,
encryptedData
);
// 4. 解析JSON
const decryptedText = new TextDecoder().decode(decryptedBuffer);
const rawServerKeys = JSON.parse(decryptedText);
const serverKeys = {
publicKey: rawServerKeys.server_public_key,
privateKey: rawServerKeys.server_private_key,
};
// 5. 保存到localStorage
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(serverKeys));
return serverKeys;
}
/**
* 获取服务端密钥
*/
export async function getServerKeys(): Promise<ServerKeys> {
// 先尝试从缓存获取
const cached = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
if (cached) {
try {
return JSON.parse(cached);
} catch (e) {
console.error("Failed to parse stored server keys:", e);
}
}
// 如果缓存不存在,抛出错误(SaveImage场景)
// 或者尝试交换密钥(UploadImage场景)
throw new Error("Server keys not found. Please upload an image first to exchange keys.");
}
/**
* 获取服务端密钥(带自动交换)
*/
export async function getServerKeysWithExchange(): Promise<ServerKeys> {
return await exchangeKeys();
}
+144
View File
@@ -0,0 +1,144 @@
/**
* 加密/解密工具模块
* 提供文件和数据的加密解密功能
*/
import { getServerKeysWithExchange, getServerKeys, importPublicKeyFromPem, importPrivateKeyFromPem } from "./cryptoKeys.js";
/**
* 使用混合加密方案加密文件,使用服务端公钥加密 AES 密钥
* @param file 要加密的文件
* @returns 加密后的数据
*/
export async function encryptFile(file: File): Promise<Uint8Array> {
const serverKeys = await getServerKeysWithExchange();
// 读取文件内容
const arrayBuffer = await file.arrayBuffer();
const data = new Uint8Array(arrayBuffer);
// 1. 生成随机 AES 密钥
const aesKey = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt"]
);
// 2. 生成随机 IV
const iv = window.crypto.getRandomValues(new Uint8Array(12));
// 3. 使用 AES 加密文件数据
const encryptedData = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
data
);
// 4. 导出 AES 密钥
const rawAesKey = await window.crypto.subtle.exportKey("raw", aesKey);
// 5. 使用服务端公钥加密 AES 密钥
const serverPublicKey = await importPublicKeyFromPem(
serverKeys.publicKey
);
const encryptedAesKey = await window.crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
serverPublicKey,
rawAesKey
);
// 6. 组合数据
const encryptedAesKeyArray = new Uint8Array(encryptedAesKey);
const encryptedDataArray = new Uint8Array(encryptedData);
const combined = new Uint8Array(
4 + encryptedAesKeyArray.length + 4 + iv.length + encryptedDataArray.length
);
const view = new DataView(combined.buffer);
view.setUint32(0, encryptedAesKeyArray.length, false);
combined.set(encryptedAesKeyArray, 4);
view.setUint32(4 + encryptedAesKeyArray.length, iv.length, false);
combined.set(iv, 4 + encryptedAesKeyArray.length + 4);
combined.set(encryptedDataArray, 4 + encryptedAesKeyArray.length + 4 + iv.length);
return combined;
}
/**
* 解密文件数据,使用服务端私钥解密 AES 密钥
* @param encryptedData 加密的数据
* @returns 解密后的数据
*/
export async function decryptFile(encryptedData: Uint8Array): Promise<Uint8Array> {
const serverKeys = await getServerKeys();
// 解析组合数据
let offset = 0;
// 1. 读取加密的AES密钥长度
const view = new DataView(encryptedData.buffer);
const encryptedAesKeyLength = view.getUint32(offset, false);
offset += 4;
// 2. 读取加密的AES密钥
const encryptedAesKey = encryptedData.slice(
offset,
offset + encryptedAesKeyLength
);
offset += encryptedAesKeyLength;
// 3. 读取IV长度
const ivLength = view.getUint32(offset, false);
offset += 4;
// 4. 读取IV
const iv = encryptedData.slice(offset, offset + ivLength);
offset += ivLength;
// 5. 读取加密的数据
const encrypted = encryptedData.slice(offset);
// 6. 使用服务端私钥解密AES密钥
const serverPrivateKey = await importPrivateKeyFromPem(
serverKeys.privateKey
);
const aesKeyBuffer = await window.crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
serverPrivateKey,
encryptedAesKey
);
// 7. 导入AES密钥
const aesKey = await window.crypto.subtle.importKey(
"raw",
aesKeyBuffer,
{
name: "AES-GCM",
length: 256,
},
false,
["decrypt"]
);
// 8. 使用AES密钥解密数据
const decryptedBuffer = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
aesKey,
encrypted
);
return new Uint8Array(decryptedBuffer);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 文件工具模块
* 提供文件相关的实用函数
*/
/**
* 从文件名中提取MIME类型
* @param filename 文件名
* @returns MIME类型字符串
*/
export function getMimeTypeFromFilename(filename: string): string {
const baseFilename = filename.replace(/\.encrypted$/, '');
// 提取原始扩展名
const lastDotIndex = baseFilename.lastIndexOf('.');
if (lastDotIndex === -1) {
// 如果没有扩展名,默认返回image/png
return 'image/png';
}
const extension = baseFilename.substring(lastDotIndex + 1).toLowerCase();
// 扩展名到MIME类型的映射
const mimeTypes: { [key: string]: string } = {
// 图片格式
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'tiff': 'image/tiff',
'tif': 'image/tiff',
// 视频格式
'mp4': 'video/mp4',
'webm': 'video/webm',
'mkv': 'video/x-matroska'
};
return mimeTypes[extension] || 'image/png'; // 默认返回image/png
}
+95
View File
@@ -0,0 +1,95 @@
/**
* 图片加载模块
* 负责从服务器加载并解密图片用于预览
*/
import type { ComfyApi } from "@comfyorg/comfyui-frontend-types";
import { decryptFile } from "./cryptoUtils.js";
import { getMimeTypeFromFilename } from "./fileUtils.js";
export interface ImageLoadParams {
filename: string;
subfolder?: string;
type: string;
}
/**
* 从简单文件名加载加密图片(用于UploadImage场景)
* @param api ComfyUI API实例
* @param filename 文件名
* @returns Blob URL
*/
export async function loadEncryptedImageFromFilename(filename: string): Promise<string> {
// 从服务器获取加密数据(二进制格式)
const response = await fetch(
`/cryptio/view_encrypted?filename=${encodeURIComponent(filename)}`
);
if (!response.ok) {
throw new Error(`Failed to load encrypted image: ${response.statusText}`);
}
// 直接读取二进制数据
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 解密数据
const decryptedData = await decryptFile(encryptedData);
// 转换为Blob URL
const blob = new Blob([new Uint8Array(decryptedData)], {
type: getMimeTypeFromFilename(filename),
});
return URL.createObjectURL(blob);
}
/**
* 从参数加载加密图片(用于SaveImage/PreviewImage场景)
* @param api ComfyUI API实例
* @param params 图片参数(包含filename, subfolder, type)
* @returns Blob URL
*/
export async function loadEncryptedImageFromParams(api: ComfyApi, params: ImageLoadParams): Promise<string> {
// 构建URL
const url = api.apiURL(
`/view?filename=${encodeURIComponent(params.filename)}&type=${params.type}${
params.subfolder ? `&subfolder=${encodeURIComponent(params.subfolder)}` : ""
}`
);
// 从服务器获取加密数据(二进制格式)
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load encrypted image: ${response.statusText}`);
}
// 直接读取二进制数据
const encryptedArrayBuffer = await response.arrayBuffer();
const encryptedData = new Uint8Array(encryptedArrayBuffer);
// 解密数据
const decryptedData = await decryptFile(encryptedData);
// 转换为Blob URL
const blob = new Blob([new Uint8Array(decryptedData)], {
type: getMimeTypeFromFilename(params.filename),
});
return URL.createObjectURL(blob);
}
/**
* 下载解密后的图片
* @param blobUrl Blob URL
* @param filename 原始文件名(带 .encrypted 后缀)
*/
export function downloadDecryptedImage(blobUrl: string, filename: string): void {
const a = document.createElement("a");
a.href = blobUrl;
// 移除 .encrypted 后缀
const originalFilename = filename.replace(/\.encrypted$/, "");
a.download = originalFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 上传工具模块
* 提供文件上传相关功能
*/
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
import { encryptFile } from "./cryptoUtils.js";
import { loadEncryptedImageFromFilename } from "./imageLoader.js";
/**
* 上传加密图片到服务器
* @param file 要上传的文件
* @returns 服务器响应
*/
export async function uploadEncryptedImage(file: File): Promise<any> {
console.log("Encrypting and uploading image:", file.name);
// 加密文件(如果文件已经加密则直接读取)
const encryptedData = file?.name?.endsWith(".encrypted")
? new Uint8Array(await file.arrayBuffer())
: await encryptFile(file);
// 创建FormData
const formData = new FormData();
const encryptedBlob = new Blob([new Uint8Array(encryptedData)], {
type: "application/octet-stream",
});
formData.append("image", encryptedBlob, file.name);
// 上传到服务器
const response = await fetch("/cryptio/upload_encrypted", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
return await response.json();
}
/**
* 处理文件上传到节点的通用方法
* @param node 节点实例
* @param file 要上传的文件
* @param app ComfyUI App实例
*/
export async function handleFileUpload(node: any, file: File, app: ComfyApp) {
try {
// 上传加密图片
const result = await uploadEncryptedImage(file);
// 更新image widget的值
const imageWidget = node.widgets.find(
(w: any) => w.name === "image"
);
if (imageWidget) {
imageWidget.value = result.name;
}
// 更新预览
await node.updatePreview(result.name);
app.rootGraph?.setDirtyCanvas(true, false);
} catch (error) {
console.error("Upload error:", error);
alert("Failed to upload encrypted image: " + error);
}
}
/**
* 创建节点预览更新函数
* @param app ComfyUI App实例
* @returns 预览更新函数
*/
export function createPreviewUpdateFunction(app: ComfyApp) {
return async function (this: any, filename: string) {
if (!filename || !filename.endsWith(".encrypted")) {
return;
}
try {
const imageUrl = await loadEncryptedImageFromFilename(filename);
// 更新节点的图片显示
const img = new Image();
img.onload = () => {
this.imgs = [img];
app.rootGraph?.setDirtyCanvas(true, false);
};
img.src = imageUrl;
} catch (error) {
console.error("Preview error:", error);
}
};
}