refactor(sw): bidirectional SW status sync with IndexedDB persistence
Replace fire-and-forget key delivery with a sync-status protocol that verifies key consistency between main thread and Service Worker using SHA-256 fingerprints via MessageChannel request-response. - SW (ts/sw.ts): persist keys in IndexedDB, auto-restore on activate; respond to sync-status queries with loaded keys + fingerprints; support cryptio-clear-keys to wipe memory + DB. - New ts/utils/swSync.ts: single entry point for SW registration, key sync, and status verification. Controller change listener re-syncs on SW restart. Deduplicates concurrent sync calls. - Deleted ts/utils/swKeySender.ts (replaced by swSync.ts). - Remove SW registration (~55 lines) from cryptioSaveImage.ts; setup() now calls syncSWStatus(). - uploadUtils.ts: drop 5-retry preview loop (~6.4s worst case); single attempt with syncSWStatus fallback on failure. - cryptioSettings.ts: sync after key changes; clearSWKeys() on Clear All. - cryptioUploadImage/Video.ts: syncSWStatus in setup + preview fallback. - types.ts: add SWStatus interface.
This commit is contained in:
+1
-1
@@ -2,7 +2,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
/js
|
||||
# OSX useful to ignore
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
@@ -102,6 +101,7 @@ cookiecutter-pypackage-env/
|
||||
*.code-workspace
|
||||
|
||||
# Frontend extension
|
||||
js/
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# CryptIO Development Guide
|
||||
|
||||
> **Keep this file in sync.** When architecture, commands, references, or conventions change, update this file so future sessions stay accurate.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Python: dev install (live-reload on ComfyUI restart)
|
||||
pip install -e .[dev]
|
||||
|
||||
# TypeScript: compile to js/
|
||||
cd ts && pnpm build
|
||||
|
||||
# TypeScript: type-check only (no emit)
|
||||
cd ts && pnpm check
|
||||
|
||||
# Lint (ruff check --fix) + format (ruff format)
|
||||
pre-commit run --all-files
|
||||
|
||||
# Lint only (CI does this)
|
||||
ruff check .
|
||||
```
|
||||
|
||||
**To test changes**: restart ComfyUI. Python changes are picked up via editable install. JS changes are served from `./js` (set by `WEB_DIRECTORY`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Encryption Flow
|
||||
|
||||
```
|
||||
Upload: Browser --[server public key]--> AES-GCM encrypt --> Server decrypts with server private key
|
||||
Output: Server --[client public key]--> AES-GCM encrypt --> Browser decrypts with client private key
|
||||
KeyEx: Browser sends client pubkey --> Server sends encrypted server keypair (RSA+AES-CBC)
|
||||
```
|
||||
|
||||
- **Server keypair** → `keys/keys.json` (auto-generated on first run, gitignored via `/keys`)
|
||||
- **Key store at runtime** → ComfyUI's system user directory (`folder_paths.get_system_user_directory()/cryptio/keys/`), NOT the repo's `keys/`
|
||||
- **Client keypair** → browser `localStorage` (`cryptio_client_keypair`, `cryptio_server_keys`)
|
||||
- **Crypto primitives**: RSA-2048 for key wrapping, AES-GCM for data, AES-CBC for key exchange
|
||||
|
||||
### Nodes (all V3 `io.ComfyNode`)
|
||||
|
||||
All nodes use the V3 pattern: `define_schema()` (Schema + widget inputs), `execute()`, `fingerprint_inputs()`, `validate_inputs()`.
|
||||
|
||||
| File | Nodes | Output Types |
|
||||
|------|-------|-------------|
|
||||
| `text_crypt.py` | TextEncrypt, TextDecrypt | String |
|
||||
| `upload_image.py` | UploadImageCryptIO | Image + Mask |
|
||||
| `save_image.py` | SaveImageCryptIO, PreviewImageCryptIO | UI (cryptio_images) |
|
||||
| `upload_video.py` | UploadVideoCryptIO | Video |
|
||||
| `save_video.py` | SaveVideoCryptIO, PreviewVideoCryptIO | UI (cryptio_images, animated) |
|
||||
|
||||
### Extension Registration
|
||||
|
||||
`__init__.py` uses `comfy_entrypoint()` returning a `ComfyExtension` subclass. Nodes are registered via `get_node_list()` returning `NODE_CLASS_MAPPINGS.values()`. The old `NODE_CLASS_MAPPINGS` + `NODE_DISPLAY_NAME_MAPPINGS` dicts are still defined in `nodes.py` for backwards compat.
|
||||
|
||||
### Key Manager
|
||||
|
||||
`KeyManager` is a singleton (`src/cryptio/utils/key_utils.py`). It caches keys in memory and checks file mtime to detect external changes. The global instance is `_key_manager`.
|
||||
|
||||
### File Layout
|
||||
|
||||
```
|
||||
__init__.py # WEB_DIRECTORY = "./js", comfy_entrypoint, ComfyExtension
|
||||
src/cryptio/
|
||||
nodes.py # Node class imports + NODE_CLASS_MAPPINGS/NODE_DISPLAY_NAME_MAPPINGS
|
||||
api.py # HTTP routes: /cryptio/exchange_keys, /upload_encrypted, /view_encrypted
|
||||
text_crypt.py # TextEncrypt, TextDecrypt
|
||||
upload_image.py # UploadImageCryptIO
|
||||
save_image.py # SaveImageCryptIO, PreviewImageCryptIO
|
||||
upload_video.py # UploadVideoCryptIO
|
||||
save_video.py # SaveVideoCryptIO, PreviewVideoCryptIO
|
||||
utils/
|
||||
crypto_utils.py # RSA+AES-GCM hybrid encrypt/decrypt, AES-CBC helpers
|
||||
key_utils.py # KeyManager singleton
|
||||
|
||||
ts/ # TypeScript source (compiled to js/)
|
||||
cryptioSettings.ts # Settings panel UI
|
||||
cryptioUploadImage.ts / cryptioUploadVideo.ts # Upload nodes
|
||||
cryptioSaveImage.ts / cryptioSaveVideo.ts # Save/Preview nodes
|
||||
textEncrypt.ts # Text encrypt node
|
||||
utils/ # cryptoKeys, cryptoUtils, uploadUtils, fileUtils, loaders
|
||||
|
||||
js/ # Compiled JS (served to browser, WEB_DIRECTORY); not in .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
All references are **read-only** — do not modify files in these locations.
|
||||
|
||||
- **ComfyUI node development skills** available in `.agents/skills/` (basics, inputs, outputs, datatypes, lifecycle, frontend, advanced, migration, packaging). Use the `skill` tool to load them when needed.
|
||||
- **ComfyUI**: `D:\CODE\ComfyUI` — the ComfyUI installation directory. Reference for `folder_paths`, `comfy_api`, base types, and node infrastructure.
|
||||
- **ComfyUI frontend source**: `D:\CODE\ComfyUI_frontend` — consult when debugging frontend integration (settings system, node UI, extension APIs).
|
||||
|
||||
## CI
|
||||
|
||||
- **build-pipeline.yml**: runs on PR to main/master — `ruff check .` then `pytest tests/`
|
||||
- **validate.yml**: runs node-diff for backwards compatibility on PR
|
||||
- **publish_node.yml**: publishes to Comfy registry
|
||||
|
||||
Note: `tests/` directory exists but is currently empty.
|
||||
|
||||
---
|
||||
|
||||
## Settings / Frontend Customization
|
||||
|
||||
### SettingCustomRenderer API
|
||||
|
||||
CryptIO uses custom DOM rendering for settings instead of built-in toggle/select types:
|
||||
|
||||
```typescript
|
||||
// type must be a function returning HTMLElement, NOT "boolean" or "combo"
|
||||
{
|
||||
id: "cryptio.key_management",
|
||||
type: renderCryptIOSettings, // (name, setter, value, attrs?) => HTMLElement
|
||||
defaultValue: null,
|
||||
category: ["CryptIO🔒", "Key Management"],
|
||||
}
|
||||
```
|
||||
|
||||
Called by `FormItem.vue` -> `CustomFormValue.vue` in ComfyUI frontend.
|
||||
|
||||
### Category / Nav Structure
|
||||
|
||||
- `category` **must have >= 2 levels** for a setting to appear as a nav tree node. Single-element `["CryptIO🔒"]` becomes a "floating" leaf under "Other".
|
||||
- Dedicated nav group for CryptIO required modifying `useSettingUI.ts` in ComfyUI frontend — not part of this repo.
|
||||
|
||||
### ComfyUI APIs (prefer these over raw DOM)
|
||||
|
||||
```typescript
|
||||
// Toasts
|
||||
app.extensionManager.toast.add({ severity, summary, detail, life })
|
||||
|
||||
// Confirmation dialogs (instead of window.confirm)
|
||||
app.extensionManager.dialog.confirm({ title, message })
|
||||
|
||||
// Setting access
|
||||
app.extensionManager.setting.set("id", value)
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
Inject a `<style>` element in `setup()`. Use ComfyUI CSS variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `--p-primary-color` | Accent / button primary |
|
||||
| `--base-background` | Card background |
|
||||
| `--base-foreground` | Text color |
|
||||
| `--secondary-background` | Subtle card background |
|
||||
| `--border-default` / `--border-subtle` | Borders |
|
||||
| `--text-muted` | Secondary text |
|
||||
| `--destructive-background` | Danger/delete |
|
||||
| `--success-background` | Success |
|
||||
|
||||
Use `pi pi-*` classes for PrimeIcons (e.g. `pi pi-refresh`, `pi pi-download`, `pi pi-trash`).
|
||||
|
||||
---
|
||||
|
||||
## Interaction Rules
|
||||
|
||||
- **Answer first, then act.** When the user asks a question or reports an issue, always respond to the question first before making any code changes. Do not jump directly into editing files. This includes informational queries, clarifications, and requests for references.
|
||||
- When the user assigns a task, proceed directly — no need to pre-confirm.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **ComfyUI restart needed** for any Python changes or recompiled JS. No hot reload.
|
||||
- **`category` must have >= 2 levels** for a setting to appear as a non-leaf nav node.
|
||||
- The `app` singleton from `../../scripts/app.js` may not have `extensionManager` populated at module load time. Access it lazily inside functions.
|
||||
- Encrypted files are named `<original>.encrypted` on disk. The browser strips `.encrypted` suffix after decryption for display.
|
||||
- All `.encrypted` files in the input directory appear as selectable Combo options in Upload nodes.
|
||||
- **Save/Preview nodes** output encrypted files to disk; decryption happens in the browser via the TS frontend.
|
||||
- **Upload nodes** read encrypted files from disk, decrypt them server-side using `_key_manager.server_private_key`.
|
||||
- Save nodes require client public key (established via key exchange when first upload occurs). They will raise `ValueError` if it's missing.
|
||||
- `ruff` config targets `py39` in `pyproject.toml` but `requires-python` is `>=3.10`. Do not use `match`/`case` without verifying.
|
||||
+11
-3
@@ -1,14 +1,22 @@
|
||||
"""Top-level package for cryptio."""
|
||||
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
|
||||
from .src.cryptio.nodes import NODE_CLASS_MAPPINGS as _NODE_CLASS_MAPPINGS
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
__all__ = ["WEB_DIRECTORY", "comfy_entrypoint"]
|
||||
|
||||
|
||||
__author__ = """ComfyUI-CryptoIO"""
|
||||
__email__ = "thesoup@foxmail.com"
|
||||
__version__ = "0.0.1"
|
||||
|
||||
from .src.cryptio.nodes import NODE_CLASS_MAPPINGS
|
||||
from .src.cryptio.nodes import NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
class CryptIOExtension(ComfyExtension):
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return list(_NODE_CLASS_MAPPINGS.values())
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> CryptIOExtension:
|
||||
return CryptIOExtension()
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* CryptIO Settings - ComfyUI Settings Integration
|
||||
* Rich key management UI using custom renderer in the settings panel
|
||||
*/
|
||||
//@ts-ignore
|
||||
import { app as rawApp } from "../../scripts/app.js";
|
||||
import { CLIENT_KEYPAIR_STORAGE_KEY, SERVER_KEYS_STORAGE_KEY, } from "./utils/cryptoKeys.js";
|
||||
const app = rawApp;
|
||||
const SETTINGS_STYLES = `
|
||||
.cryptio-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
.cryptio-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted, #8a8a8a);
|
||||
margin-bottom: 2px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-subtle, #3c3d42);
|
||||
}
|
||||
.cryptio-section-title-danger {
|
||||
color: var(--destructive-background, #f75951);
|
||||
border-bottom-color: var(--destructive-background, #f75951);
|
||||
}
|
||||
.cryptio-status-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle, #3c3d42);
|
||||
background: var(--secondary-background, #262729);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.cryptio-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cryptio-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cryptio-status-label {
|
||||
font-weight: 500;
|
||||
color: var(--base-foreground, #fff);
|
||||
}
|
||||
.cryptio-status-detail {
|
||||
color: var(--text-muted, #8a8a8a);
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
.cryptio-btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.cryptio-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-default, #494a50);
|
||||
border-radius: 6px;
|
||||
background: var(--base-background, #171718);
|
||||
color: var(--base-foreground, #fff);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cryptio-btn:hover {
|
||||
background: var(--secondary-background-hover, #313235);
|
||||
border-color: var(--p-primary-color, #0b8ce9);
|
||||
}
|
||||
.cryptio-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cryptio-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
.cryptio-btn-primary {
|
||||
background: var(--p-primary-color, #0b8ce9);
|
||||
color: #fff;
|
||||
border-color: var(--p-primary-color, #0b8ce9);
|
||||
}
|
||||
.cryptio-btn-primary:hover {
|
||||
background: var(--p-primary-hover-color, #185a8b);
|
||||
border-color: var(--p-primary-hover-color, #185a8b);
|
||||
}
|
||||
.cryptio-btn-danger {
|
||||
background: var(--destructive-background, #b33a3a);
|
||||
color: #fff;
|
||||
border-color: var(--destructive-background, #b33a3a);
|
||||
}
|
||||
.cryptio-btn-danger:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
`;
|
||||
function injectStyles() {
|
||||
if (document.getElementById("cryptio-settings-styles"))
|
||||
return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "cryptio-settings-styles";
|
||||
style.textContent = SETTINGS_STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
// ──────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
function getExtMgr() {
|
||||
return app.extensionManager;
|
||||
}
|
||||
function showToast(message, severity = "info") {
|
||||
const em = getExtMgr();
|
||||
if (em?.toast?.add) {
|
||||
em.toast.add({ severity, summary: "CryptIO", detail: message, life: 3000 });
|
||||
}
|
||||
}
|
||||
async function showConfirm(title, message) {
|
||||
const em = getExtMgr();
|
||||
if (em?.dialog?.confirm) {
|
||||
try {
|
||||
const result = await em.dialog.confirm({ title, message });
|
||||
return result === true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return window.confirm(message);
|
||||
}
|
||||
function downloadJSON(data, filename) {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
function readJSONFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target?.result);
|
||||
resolve(data);
|
||||
}
|
||||
catch {
|
||||
reject(new Error("Invalid JSON file"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
function createFileInput(id, onChange) {
|
||||
const existing = document.getElementById(id);
|
||||
if (existing)
|
||||
existing.remove();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.id = id;
|
||||
input.accept = ".json";
|
||||
input.style.display = "none";
|
||||
input.addEventListener("change", (event) => {
|
||||
onChange(event);
|
||||
input.remove();
|
||||
});
|
||||
document.body.appendChild(input);
|
||||
return input;
|
||||
}
|
||||
function triggerFileUpload(id, handler) {
|
||||
const input = createFileInput(id, handler);
|
||||
input.click();
|
||||
}
|
||||
function getKeyInfo(storageKey) {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw)
|
||||
return { present: false, keyType: "" };
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
const pubKey = data.publicKey || "";
|
||||
if (pubKey.includes("RSA"))
|
||||
return { present: true, keyType: "RSA" };
|
||||
if (pubKey.includes("PUBLIC"))
|
||||
return { present: true, keyType: "Public Key" };
|
||||
return { present: true, keyType: "Configured" };
|
||||
}
|
||||
catch {
|
||||
return { present: false, keyType: "" };
|
||||
}
|
||||
}
|
||||
// ──────────────────────────────────────────────
|
||||
// Action handlers
|
||||
// ──────────────────────────────────────────────
|
||||
async function handleGenerate() {
|
||||
const confirmed = await showConfirm("Generate New Client Keys", "This will overwrite your existing client keypair and cannot be undone. Continue?");
|
||||
if (!confirmed)
|
||||
return;
|
||||
try {
|
||||
const { generateClientKeyPair } = await import("./utils/cryptoKeys.js");
|
||||
const keyPair = await generateClientKeyPair();
|
||||
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
|
||||
showToast("New client keys generated", "success");
|
||||
}
|
||||
catch (error) {
|
||||
showToast(`Failed to generate keys: ${error}`, "error");
|
||||
}
|
||||
}
|
||||
async function handleClear() {
|
||||
const confirmed = await showConfirm("Clear All Keys", "This will remove all CryptIO keys from localStorage. This action cannot be undone.");
|
||||
if (!confirmed)
|
||||
return;
|
||||
localStorage.removeItem(CLIENT_KEYPAIR_STORAGE_KEY);
|
||||
localStorage.removeItem(SERVER_KEYS_STORAGE_KEY);
|
||||
showToast("All keys cleared", "success");
|
||||
}
|
||||
function handleDownloadClient() {
|
||||
const raw = localStorage.getItem(CLIENT_KEYPAIR_STORAGE_KEY);
|
||||
if (raw) {
|
||||
downloadJSON(JSON.parse(raw), "cryptio-client-keypair.json");
|
||||
showToast("Client keys downloaded", "success");
|
||||
}
|
||||
else {
|
||||
showToast("No client keys found", "error");
|
||||
}
|
||||
}
|
||||
function handleDownloadServer() {
|
||||
const raw = localStorage.getItem(SERVER_KEYS_STORAGE_KEY);
|
||||
if (raw) {
|
||||
downloadJSON(JSON.parse(raw), "cryptio-server-keys.json");
|
||||
showToast("Server keys downloaded", "success");
|
||||
}
|
||||
else {
|
||||
showToast("No server keys found", "error");
|
||||
}
|
||||
}
|
||||
async function handleUploadClient(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file)
|
||||
return;
|
||||
try {
|
||||
const data = await readJSONFile(file);
|
||||
if (data.publicKey && data.privateKey) {
|
||||
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(data));
|
||||
showToast("Client keys uploaded", "success");
|
||||
}
|
||||
else {
|
||||
showToast("Invalid key format — must contain publicKey and privateKey", "error");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
showToast(`Failed to upload: ${error}`, "error");
|
||||
}
|
||||
}
|
||||
async function handleUploadServer(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file)
|
||||
return;
|
||||
try {
|
||||
const data = await readJSONFile(file);
|
||||
if (data.publicKey && data.privateKey) {
|
||||
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(data));
|
||||
showToast("Server keys uploaded", "success");
|
||||
}
|
||||
else {
|
||||
showToast("Invalid key format — must contain publicKey and privateKey", "error");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
showToast(`Failed to upload: ${error}`, "error");
|
||||
}
|
||||
}
|
||||
// ──────────────────────────────────────────────
|
||||
// DOM builders
|
||||
// ──────────────────────────────────────────────
|
||||
function el(tag, className, text) {
|
||||
const e = document.createElement(tag);
|
||||
e.className = className;
|
||||
if (text !== undefined)
|
||||
e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
function makeSectionTitle(text, variant) {
|
||||
if (variant === "danger")
|
||||
return el("div", "cryptio-section-title cryptio-section-title-danger", text);
|
||||
return el("div", "cryptio-section-title", text);
|
||||
}
|
||||
function makeButton(text, icon, variant, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "cryptio-btn";
|
||||
if (variant === "primary")
|
||||
btn.classList.add("cryptio-btn-primary");
|
||||
if (variant === "danger")
|
||||
btn.classList.add("cryptio-btn-danger");
|
||||
const i = document.createElement("i");
|
||||
i.className = icon;
|
||||
btn.appendChild(i);
|
||||
btn.appendChild(document.createTextNode(" " + text));
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
function makeButtonRow(buttons) {
|
||||
const row = el("div", "cryptio-btn-row");
|
||||
buttons.forEach((b) => row.appendChild(b));
|
||||
return row;
|
||||
}
|
||||
function makeStatusCard(items) {
|
||||
const card = el("div", "cryptio-status-card");
|
||||
items.forEach((item) => {
|
||||
const row = el("div", "cryptio-status-row");
|
||||
const dot = el("span", "cryptio-status-dot");
|
||||
dot.style.backgroundColor = item.present
|
||||
? "var(--success-background, #00cd72)"
|
||||
: "var(--text-muted, #8a8a8a)";
|
||||
row.appendChild(dot);
|
||||
row.appendChild(el("span", "cryptio-status-label", item.label));
|
||||
row.appendChild(el("span", "cryptio-status-detail", item.detail));
|
||||
card.appendChild(row);
|
||||
});
|
||||
return card;
|
||||
}
|
||||
// ──────────────────────────────────────────────
|
||||
// Custom renderer (SettingCustomRenderer)
|
||||
// ──────────────────────────────────────────────
|
||||
function renderCryptIOSettings(_name, _setter, _value, _attrs) {
|
||||
const container = el("div", "cryptio-settings");
|
||||
const clientInfo = getKeyInfo(CLIENT_KEYPAIR_STORAGE_KEY);
|
||||
const serverInfo = getKeyInfo(SERVER_KEYS_STORAGE_KEY);
|
||||
// ── Key Status ──
|
||||
container.appendChild(makeSectionTitle("Key Status"));
|
||||
container.appendChild(makeStatusCard([
|
||||
{
|
||||
present: clientInfo.present,
|
||||
label: "Client Key",
|
||||
detail: clientInfo.present
|
||||
? `${clientInfo.keyType} keypair configured`
|
||||
: "Not configured",
|
||||
},
|
||||
{
|
||||
present: serverInfo.present,
|
||||
label: "Server Key",
|
||||
detail: serverInfo.present
|
||||
? `${serverInfo.keyType} keypair configured`
|
||||
: "Not configured",
|
||||
},
|
||||
]));
|
||||
// ── Client Keys ──
|
||||
container.appendChild(makeSectionTitle("Client Keys"));
|
||||
container.appendChild(makeButtonRow([
|
||||
makeButton("Generate New", "pi pi-refresh", "primary", () => handleGenerate()),
|
||||
makeButton("Download", "pi pi-download", "default", () => handleDownloadClient()),
|
||||
makeButton("Upload", "pi pi-upload", "default", () => triggerFileUpload("cryptio-client-upload", handleUploadClient)),
|
||||
]));
|
||||
// ── Server Keys ──
|
||||
container.appendChild(makeSectionTitle("Server Keys"));
|
||||
container.appendChild(makeButtonRow([
|
||||
makeButton("Download", "pi pi-download", "default", () => handleDownloadServer()),
|
||||
makeButton("Upload", "pi pi-upload", "default", () => triggerFileUpload("cryptio-server-upload", handleUploadServer)),
|
||||
]));
|
||||
// ── Danger Zone ──
|
||||
container.appendChild(makeSectionTitle("Danger Zone", "danger"));
|
||||
container.appendChild(makeButtonRow([
|
||||
makeButton("Clear All Keys", "pi pi-trash", "danger", () => handleClear()),
|
||||
]));
|
||||
return container;
|
||||
}
|
||||
// ──────────────────────────────────────────────
|
||||
// Extension registration
|
||||
// ──────────────────────────────────────────────
|
||||
app.registerExtension({
|
||||
name: "cryptio.Settings",
|
||||
settings: [
|
||||
{
|
||||
id: "cryptio.category",
|
||||
name: "CryptIO Settings",
|
||||
type: "hidden",
|
||||
defaultValue: "",
|
||||
category: ["CryptIO🔒", "Key Management"],
|
||||
},
|
||||
{
|
||||
id: "cryptio.key_management",
|
||||
name: "Key Management",
|
||||
type: renderCryptIOSettings,
|
||||
defaultValue: null,
|
||||
category: ["CryptIO🔒", "Key Management"],
|
||||
},
|
||||
],
|
||||
async setup() {
|
||||
injectStyles();
|
||||
console.log("[CryptIO] Settings extension loaded");
|
||||
},
|
||||
});
|
||||
+4
-1
@@ -11,7 +11,10 @@ readme = "README.md"
|
||||
license = { text = "GNU General Public License v3" }
|
||||
requires-python = ">=3.10"
|
||||
classifiers = []
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"av",
|
||||
"cryptography",
|
||||
]
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"comfyui-node-advanced": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-advanced/SKILL.md",
|
||||
"computedHash": "c096e68c33360e28abe06b69e531b83f3dc826a40ea21e46b4c8a28f1fa4aefd"
|
||||
},
|
||||
"comfyui-node-basics": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-basics/SKILL.md",
|
||||
"computedHash": "160a565a09d779b4e19cd0c61b9ee1d5e9491e0fe54b648632c15be6e14855a0"
|
||||
},
|
||||
"comfyui-node-datatypes": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-datatypes/SKILL.md",
|
||||
"computedHash": "d9c9130075d005c82c2dd406ffde1bff3035bcca4276c3ce79c40df356d3b771"
|
||||
},
|
||||
"comfyui-node-frontend": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-frontend/SKILL.md",
|
||||
"computedHash": "1853a9feb9c2c0bc759c9daa7cafd1114d01a87bfdbc2a94c595dda809d62663"
|
||||
},
|
||||
"comfyui-node-inputs": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-inputs/SKILL.md",
|
||||
"computedHash": "29888eb83e1173aed0627b2c106f7deaaa7ca59a547e6f83ff9893ea9bbd73dd"
|
||||
},
|
||||
"comfyui-node-lifecycle": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-lifecycle/SKILL.md",
|
||||
"computedHash": "a09eac7fdd8c3978f425bc2dcba18dd5a47641f342de72d8949814206aedddc9"
|
||||
},
|
||||
"comfyui-node-migration": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-migration/SKILL.md",
|
||||
"computedHash": "213863c4d3bac3aa34b38507c0fbca7eb774b50df6a20e3cba32ab3ca0057569"
|
||||
},
|
||||
"comfyui-node-outputs": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-outputs/SKILL.md",
|
||||
"computedHash": "3efce7a21b4e02f91ec9353cb2431ebc77044bff0f68ef7d81feb744f86946b3"
|
||||
},
|
||||
"comfyui-node-packaging": {
|
||||
"source": "jtydhr88/comfyui-custom-node-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/comfyui-custom-nodes/skills/comfyui-node-packaging/SKILL.md",
|
||||
"computedHash": "a64a0353af48aeb414c55d3826a1a2d512604c497912503c538497f8311588dd"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,23 @@ async def view_encrypted_image(request):
|
||||
except Exception as e:
|
||||
print(f"Error viewing encrypted image: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/cryptio-sw.js")
|
||||
async def serve_service_worker(request):
|
||||
"""Serve the Service Worker script from root so its natural scope is '/'.
|
||||
|
||||
Served from /cryptio-sw.js (no subdirectory) → natural scope is /.
|
||||
No Service-Worker-Allowed header needed.
|
||||
"""
|
||||
sw_path = os.path.join(os.path.dirname(__file__), "..", "..", "js", "sw.js")
|
||||
if not os.path.exists(sw_path):
|
||||
return web.json_response({"error": "Service Worker script not found"}, status=404)
|
||||
|
||||
with open(sw_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return web.Response(
|
||||
text=content,
|
||||
content_type="application/javascript",
|
||||
)
|
||||
|
||||
@@ -33,8 +33,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SaveImageCryptIO": "Save Image 🔒",
|
||||
"PreviewImageCryptIO": "Preview Image 🔒",
|
||||
|
||||
# 视频处理 deprecated, now defined in node schema
|
||||
# "UploadVideoCryptIO": "Upload Video 🔒",
|
||||
# "SaveVideoCryptIO": "Save Video 🔒",
|
||||
# "PreviewVideoCryptIO": "Preview Video 🔒",
|
||||
# 视频处理
|
||||
"UploadVideoCryptIO": "Upload Video 🔒",
|
||||
"SaveVideoCryptIO": "Save Video 🔒",
|
||||
"PreviewVideoCryptIO": "Preview Video 🔒",
|
||||
}
|
||||
|
||||
+127
-185
@@ -5,196 +5,138 @@ import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from io import BytesIO
|
||||
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
from comfy_api.latest import io
|
||||
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
from .utils import _key_manager
|
||||
from .utils.crypto_utils import encrypt_data_hybrid
|
||||
|
||||
|
||||
class SaveImageCryptIO:
|
||||
"""
|
||||
加密图片保存节点,将图片保存为加密的 .png.encrypted 文件
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
"format": (["PNG", "JPG", "WEBP", "AVIF"],),
|
||||
"quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
|
||||
"auto_download": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "CryptIO🔒"
|
||||
|
||||
def save_images(self, images, filename_prefix="ComfyUI", format="PNG", quality=95, auto_download=False, prompt=None, extra_pnginfo=None):
|
||||
"""
|
||||
保存并加密图片
|
||||
"""
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
results = list()
|
||||
|
||||
for batch_number, image in enumerate(images):
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
# 添加元数据 (仅PNG支持)
|
||||
metadata = None
|
||||
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
# 保存为指定格式到内存
|
||||
from io import BytesIO
|
||||
|
||||
file_ext = format.lower()
|
||||
if format == "JPG":
|
||||
file_ext = "jpg"
|
||||
format_key = "JPEG"
|
||||
else:
|
||||
format_key = format
|
||||
|
||||
buffer = BytesIO()
|
||||
if format == "PNG":
|
||||
img.save(buffer, format="PNG", pnginfo=metadata, compress_level=self.compress_level)
|
||||
else:
|
||||
# JPG/WEBP/AVIF ignore metadata for now
|
||||
img.save(buffer, format=format_key, quality=quality)
|
||||
|
||||
image_data = buffer.getvalue()
|
||||
|
||||
# 加密数据
|
||||
encrypted_data = encrypt_data(image_data)
|
||||
|
||||
# 保存加密数据到文件
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
|
||||
encrypted_path = os.path.join(full_output_folder, file)
|
||||
|
||||
with open(encrypted_path, "wb") as f:
|
||||
f.write(encrypted_data)
|
||||
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"cryptio_images": results}}
|
||||
|
||||
class PreviewImageCryptIO:
|
||||
"""
|
||||
加密图片预览节点,类似SaveImage但用于临时预览
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_temp_directory()
|
||||
self.type = "temp"
|
||||
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
|
||||
self.compress_level = 1
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"format": (["PNG", "JPG", "WEBP", "AVIF"],),
|
||||
"quality": ("INT", {"default": 95, "min": 1, "max": 100, "step": 1}),
|
||||
"auto_download": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "CryptIO🔒"
|
||||
|
||||
def save_images(self, images, auto_download=False, filename_prefix="ComfyUI", format="PNG", quality=95, prompt=None, extra_pnginfo=None):
|
||||
"""
|
||||
保存并加密图片用于预览
|
||||
"""
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
results = list()
|
||||
|
||||
for batch_number, image in enumerate(images):
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
# 添加元数据 (仅PNG支持)
|
||||
metadata = None
|
||||
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
# 保存为指定格式到内存
|
||||
from io import BytesIO
|
||||
|
||||
file_ext = format.lower()
|
||||
if format == "JPG":
|
||||
file_ext = "jpg"
|
||||
format_key = "JPEG"
|
||||
else:
|
||||
format_key = format
|
||||
|
||||
buffer = BytesIO()
|
||||
if format == "PNG":
|
||||
img.save(buffer, format="PNG", pnginfo=metadata, compress_level=self.compress_level)
|
||||
else:
|
||||
img.save(buffer, format=format_key, quality=quality)
|
||||
|
||||
image_data = buffer.getvalue()
|
||||
|
||||
# 加密数据
|
||||
encrypted_data = encrypt_data(image_data)
|
||||
|
||||
# 保存加密数据到文件
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
|
||||
encrypted_path = os.path.join(full_output_folder, file)
|
||||
|
||||
with open(encrypted_path, "wb") as f:
|
||||
f.write(encrypted_data)
|
||||
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"cryptio_images": results}}
|
||||
|
||||
def encrypt_data(data: bytes) -> bytes:
|
||||
"""
|
||||
使用混合加密方案加密数据(使用客户端公钥加密)
|
||||
"""
|
||||
# 使用客户端公钥加密(这样只有客户端能解密)
|
||||
def _encrypt_data(data: bytes) -> bytes:
|
||||
if not _key_manager.client_public_key:
|
||||
raise ValueError("Client public key not found. Please upload an image first to exchange keys.")
|
||||
|
||||
return encrypt_data_hybrid(data, _key_manager.client_public_key)
|
||||
|
||||
|
||||
def _encode_and_encrypt(image: Image.Image, format: str, quality: int, compress_level: int, prompt=None, extra_pnginfo=None):
|
||||
"""Encode image to bytes and encrypt."""
|
||||
metadata = None
|
||||
if format == "PNG" and (prompt is not None or extra_pnginfo is not None):
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
file_ext = format.lower()
|
||||
format_key = "JPEG" if format == "JPG" else format
|
||||
|
||||
buffer = BytesIO()
|
||||
if format == "PNG":
|
||||
image.save(buffer, format="PNG", pnginfo=metadata, compress_level=compress_level)
|
||||
else:
|
||||
image.save(buffer, format=format_key, quality=quality)
|
||||
|
||||
encrypted_data = _encrypt_data(buffer.getvalue())
|
||||
return encrypted_data, file_ext
|
||||
|
||||
|
||||
class SaveImageCryptIO(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SaveImageCryptIO",
|
||||
display_name="Save Image 🔒",
|
||||
category="CryptIO🔒",
|
||||
description="Encrypt and save images to the output directory",
|
||||
inputs=[
|
||||
io.Image.Input("images", tooltip="The images to encrypt and save"),
|
||||
io.String.Input("filename_prefix", default="ComfyUI", tooltip="Prefix for the saved encrypted file"),
|
||||
io.Combo.Input("format", options=["PNG", "JPG", "WEBP", "AVIF"], tooltip="The file format in which to save the image"),
|
||||
io.Int.Input("quality", default=95, min=1, max=100, step=1, tooltip="Quality setting for the saved image"),
|
||||
io.Boolean.Input("auto_download", default=False, tooltip="Automatically download decrypted image in browser"),
|
||||
],
|
||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||
is_output_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images, filename_prefix="ComfyUI", format="PNG", quality=95, auto_download=False):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
|
||||
prompt = cls.hidden.prompt
|
||||
extra_pnginfo = cls.hidden.extra_pnginfo
|
||||
|
||||
results = []
|
||||
for batch_number, image in enumerate(images):
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
encrypted_data, file_ext = _encode_and_encrypt(img, format, quality, 4, prompt, extra_pnginfo)
|
||||
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
|
||||
encrypted_path = os.path.join(full_output_folder, file)
|
||||
|
||||
with open(encrypted_path, "wb") as f:
|
||||
f.write(encrypted_data)
|
||||
|
||||
results.append({"filename": file, "subfolder": subfolder, "type": "output"})
|
||||
counter += 1
|
||||
|
||||
return io.NodeOutput(ui={"images": results, "cryptio_images": results})
|
||||
|
||||
|
||||
class PreviewImageCryptIO(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="PreviewImageCryptIO",
|
||||
display_name="Preview Image 🔒",
|
||||
category="CryptIO🔒",
|
||||
description="Encrypt and preview images (saved to temp directory)",
|
||||
inputs=[
|
||||
io.Image.Input("images", tooltip="The images to encrypt and preview"),
|
||||
io.Combo.Input("format", options=["PNG", "JPG", "WEBP", "AVIF"], tooltip="The file format in which to save the image"),
|
||||
io.Int.Input("quality", default=95, min=1, max=100, step=1, tooltip="Quality setting for the saved image"),
|
||||
io.Boolean.Input("auto_download", default=False, tooltip="Automatically download decrypted image in browser"),
|
||||
],
|
||||
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
|
||||
is_output_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images, auto_download=False, format="PNG", quality=95):
|
||||
prefix = "_temp_" + "".join(random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5))
|
||||
output_dir = folder_paths.get_temp_directory()
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
||||
prefix, output_dir, images[0].shape[1], images[0].shape[0]
|
||||
)
|
||||
|
||||
prompt = cls.hidden.prompt
|
||||
extra_pnginfo = cls.hidden.extra_pnginfo
|
||||
|
||||
results = []
|
||||
for batch_number, image in enumerate(images):
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
|
||||
encrypted_data, file_ext = _encode_and_encrypt(img, format, quality, 1, prompt, extra_pnginfo)
|
||||
|
||||
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.{file_ext}.encrypted"
|
||||
encrypted_path = os.path.join(full_output_folder, file)
|
||||
|
||||
with open(encrypted_path, "wb") as f:
|
||||
f.write(encrypted_data)
|
||||
|
||||
results.append({"filename": file, "subfolder": subfolder, "type": "temp"})
|
||||
counter += 1
|
||||
|
||||
return io.NodeOutput(ui={"images": results, "cryptio_images": results})
|
||||
|
||||
@@ -2,7 +2,6 @@ import os
|
||||
import random
|
||||
from io import BytesIO
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
from .utils import _key_manager
|
||||
import av
|
||||
import torch
|
||||
import numpy as np
|
||||
@@ -183,8 +182,6 @@ class SaveVideoCryptIO(io.ComfyNode):
|
||||
|
||||
# Save video to in-memory buffer to avoid writing plaintext to disk
|
||||
buffer = BytesIO()
|
||||
# Save video to in-memory buffer using unified helper
|
||||
buffer = BytesIO()
|
||||
_save_to_bytesio_impl(video, buffer, metadata=metadata)
|
||||
video_data = buffer.getvalue()
|
||||
buffer.close()
|
||||
|
||||
+44
-58
@@ -1,83 +1,69 @@
|
||||
import base64
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .utils import _key_manager
|
||||
from .utils.crypto_utils import decrypt_data_hybrid
|
||||
|
||||
|
||||
class TextEncrypt:
|
||||
"""
|
||||
客户端加密节点,使用公钥加密文本
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
class TextEncrypt(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="TextEncrypt",
|
||||
display_name="Text Encrypt 🔒",
|
||||
category="CryptIO🔒",
|
||||
description="Encrypt text with server public key (encryption performed in frontend JavaScript)",
|
||||
inputs=[
|
||||
io.String.Input("text", multiline=True, default="Text to encrypt", tooltip="Text to encrypt"),
|
||||
io.Boolean.Input("encrypted", default=False, tooltip="Whether the text has been encrypted by the frontend"),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="text"),
|
||||
io.String.Output(display_name="encrypted_text"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"multiline": True, "default": "要加密的文本"}),
|
||||
"encrypted": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("text", "encrypted_text")
|
||||
FUNCTION = "crypt"
|
||||
CATEGORY = "CryptIO🔒"
|
||||
|
||||
def crypt(self, text: str, encrypted):
|
||||
"""使用公钥加密文本
|
||||
在客户端JavaScript中实现,这里只是占位符
|
||||
实际加密在前端JavaScript中完成
|
||||
"""
|
||||
def execute(cls, text, encrypted):
|
||||
try:
|
||||
if text.startswith("ENCRYPTED:"):
|
||||
encrypted_bytes = base64.b64decode(text[10:])
|
||||
|
||||
# 使用混合解密
|
||||
decrypted_data = decrypt_data_hybrid(encrypted_bytes, _key_manager.server_private_key)
|
||||
decrypted_text = decrypted_data.decode("utf-8")
|
||||
return (decrypted_text, text)
|
||||
return io.NodeOutput(decrypted_text, text)
|
||||
else:
|
||||
raise ValueError("文本不是加密格式")
|
||||
raise ValueError("Text is not in encrypted format")
|
||||
except Exception as e:
|
||||
print(f"解密错误: {e}")
|
||||
return (f"解密错误: {str(e)}",)
|
||||
print(f"Decryption error: {e}")
|
||||
return io.NodeOutput(f"Decryption error: {str(e)}", text)
|
||||
|
||||
|
||||
class TextDecrypt:
|
||||
"""
|
||||
服务端解密节点,使用私钥解密文本
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
class TextDecrypt(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="TextDecrypt",
|
||||
display_name="Text Decrypt 🔒",
|
||||
category="CryptIO🔒",
|
||||
description="Decrypt text encrypted with client public key using server private key",
|
||||
inputs=[
|
||||
io.String.Input("encrypted_text", multiline=True, default="ENCRYPTED:...", tooltip="Encrypted text to decrypt"),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(display_name="decrypted_text"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"encrypted_text": ("STRING", {"multiline": True, "default": "ENCRYPTED:加密的文本"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("decrypted_text",)
|
||||
FUNCTION = "decrypt"
|
||||
CATEGORY = "CryptIO🔒"
|
||||
|
||||
def decrypt(self, encrypted_text):
|
||||
"""使用私钥解密文本(支持混合加密格式)"""
|
||||
def execute(cls, encrypted_text):
|
||||
try:
|
||||
if encrypted_text.startswith("ENCRYPTED:"):
|
||||
encrypted_bytes = base64.b64decode(encrypted_text[10:])
|
||||
|
||||
# 使用混合解密
|
||||
decrypted_data = decrypt_data_hybrid(encrypted_bytes, _key_manager.server_private_key)
|
||||
decrypted_text = decrypted_data.decode("utf-8")
|
||||
return (decrypted_text,)
|
||||
return io.NodeOutput(decrypted_text)
|
||||
else:
|
||||
return (encrypted_text,)
|
||||
return io.NodeOutput(encrypted_text)
|
||||
except Exception as e:
|
||||
print(f"解密错误: {e}")
|
||||
return (f"解密错误: {str(e)}",)
|
||||
print(f"Decryption error: {e}")
|
||||
return io.NodeOutput(f"Decryption error: {str(e)}")
|
||||
|
||||
+82
-102
@@ -3,23 +3,19 @@ import hashlib
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageSequence, ImageOps
|
||||
from .utils import _key_manager
|
||||
from .utils.crypto_utils import decrypt_data_hybrid
|
||||
from io import BytesIO
|
||||
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
import node_helpers # pyright: ignore[reportMissingImports]
|
||||
from comfy_api.latest import io
|
||||
|
||||
from .utils import _key_manager
|
||||
from .utils.crypto_utils import decrypt_data_hybrid
|
||||
|
||||
|
||||
class UploadImageCryptIO:
|
||||
"""
|
||||
加密图片上传节点,使用公钥在客户端加密图片数据,服务端使用私钥解密
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.input_dir = folder_paths.get_input_directory()
|
||||
self.type = "input"
|
||||
|
||||
class UploadImageCryptIO(io.ComfyNode):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
def define_schema(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
||||
encrypted_files = [
|
||||
@@ -37,107 +33,91 @@ class UploadImageCryptIO:
|
||||
]
|
||||
all_files = sorted(set(encrypted_files))
|
||||
|
||||
return {
|
||||
"required": {"image": (all_files, {"file_upload": True})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "mask")
|
||||
FUNCTION = "load_image"
|
||||
CATEGORY = "CryptIO🔒"
|
||||
|
||||
def load_image(self, image):
|
||||
"""
|
||||
加载并解密图片
|
||||
"""
|
||||
try:
|
||||
# 检查是否是加密文件(文件名以 .encrypted 结尾)
|
||||
if isinstance(image, str) and image.endswith(".encrypted"):
|
||||
# 从加密文件读取
|
||||
image_path = folder_paths.get_annotated_filepath(image)
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
encrypted_combined = f.read()
|
||||
|
||||
# 使用服务端私钥解密(混合加密)
|
||||
decrypted_data = decrypt_data_hybrid(encrypted_combined, _key_manager.server_private_key)
|
||||
|
||||
# 从字节数据创建图片
|
||||
from io import BytesIO
|
||||
|
||||
img = Image.open(BytesIO(decrypted_data))
|
||||
|
||||
else:
|
||||
return (None, None)
|
||||
|
||||
# 处理图片(与LoadImage节点相同的处理逻辑)
|
||||
output_images = []
|
||||
output_masks = []
|
||||
w, h = None, None
|
||||
|
||||
for i in ImageSequence.Iterator(img):
|
||||
i = node_helpers.pillow(ImageOps.exif_transpose, i)
|
||||
|
||||
if i.mode == "I":
|
||||
i = i.point(lambda i: i * (1 / 255))
|
||||
image_rgb = i.convert("RGB")
|
||||
|
||||
if len(output_images) == 0:
|
||||
w = image_rgb.size[0]
|
||||
h = image_rgb.size[1]
|
||||
|
||||
if image_rgb.size[0] != w or image_rgb.size[1] != h:
|
||||
continue
|
||||
|
||||
image_array = np.array(image_rgb).astype(np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(image_array)[None,]
|
||||
|
||||
# 处理遮罩
|
||||
if "A" in i.getbands():
|
||||
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
|
||||
mask = 1.0 - torch.from_numpy(mask)
|
||||
elif i.mode == "P" and "transparency" in i.info:
|
||||
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
|
||||
mask = 1.0 - torch.from_numpy(mask)
|
||||
else:
|
||||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
|
||||
output_images.append(image_tensor)
|
||||
output_masks.append(mask.unsqueeze(0))
|
||||
|
||||
if len(output_images) > 1 and img.format not in ["MPO"]:
|
||||
output_image = torch.cat(output_images, dim=0)
|
||||
output_mask = torch.cat(output_masks, dim=0)
|
||||
else:
|
||||
output_image = output_images[0]
|
||||
output_mask = output_masks[0]
|
||||
|
||||
return (output_image, output_mask)
|
||||
|
||||
except Exception as e:
|
||||
print(f"加载/解密图片错误: {e}")
|
||||
raise ValueError(f"Failed to decrypt image: {e}")
|
||||
return io.Schema(
|
||||
node_id="UploadImageCryptIO",
|
||||
display_name="Upload Image 🔒",
|
||||
category="CryptIO🔒",
|
||||
description="Upload and decrypt image files encrypted with server public key",
|
||||
inputs=[
|
||||
io.Combo.Input("image", options=all_files, extra_dict={"image_upload": True}, tooltip="Select an encrypted image file to upload and decrypt"),
|
||||
],
|
||||
outputs=[
|
||||
io.Image.Output(display_name="image"),
|
||||
io.Mask.Output(display_name="mask"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, image):
|
||||
"""检查图片是否已更改"""
|
||||
def execute(cls, image):
|
||||
if not isinstance(image, str) or not image.endswith(".encrypted"):
|
||||
raise ValueError("Invalid image input, expected encrypted image file")
|
||||
|
||||
image_path = folder_paths.get_annotated_filepath(image)
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
encrypted_combined = f.read()
|
||||
|
||||
decrypted_data = decrypt_data_hybrid(encrypted_combined, _key_manager.server_private_key)
|
||||
|
||||
img = Image.open(BytesIO(decrypted_data))
|
||||
|
||||
output_images = []
|
||||
output_masks = []
|
||||
w, h = None, None
|
||||
|
||||
for i in ImageSequence.Iterator(img):
|
||||
i = node_helpers.pillow(ImageOps.exif_transpose, i)
|
||||
|
||||
if i.mode == "I":
|
||||
i = i.point(lambda i: i * (1 / 255))
|
||||
image_rgb = i.convert("RGB")
|
||||
|
||||
if len(output_images) == 0:
|
||||
w = image_rgb.size[0]
|
||||
h = image_rgb.size[1]
|
||||
|
||||
if image_rgb.size[0] != w or image_rgb.size[1] != h:
|
||||
continue
|
||||
|
||||
image_array = np.array(image_rgb).astype(np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(image_array)[None,]
|
||||
|
||||
if "A" in i.getbands():
|
||||
mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
|
||||
mask = 1.0 - torch.from_numpy(mask)
|
||||
elif i.mode == "P" and "transparency" in i.info:
|
||||
mask = np.array(i.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
|
||||
mask = 1.0 - torch.from_numpy(mask)
|
||||
else:
|
||||
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
|
||||
output_images.append(image_tensor)
|
||||
output_masks.append(mask.unsqueeze(0))
|
||||
|
||||
if len(output_images) > 1 and img.format not in ["MPO"]:
|
||||
output_image = torch.cat(output_images, dim=0)
|
||||
output_mask = torch.cat(output_masks, dim=0)
|
||||
else:
|
||||
output_image = output_images[0]
|
||||
output_mask = output_masks[0]
|
||||
|
||||
return io.NodeOutput(output_image, output_mask)
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(cls, image):
|
||||
if isinstance(image, str) and not image.startswith("ENCRYPTED:"):
|
||||
image_path = folder_paths.get_annotated_filepath(image)
|
||||
m = hashlib.sha256()
|
||||
with open(image_path, "rb") as f:
|
||||
m.update(f.read())
|
||||
return m.digest().hex()
|
||||
return float("NaN") # 对于加密数据,总是重新处理
|
||||
return float("NaN")
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(s, image):
|
||||
"""验证输入"""
|
||||
def validate_inputs(cls, image):
|
||||
if isinstance(image, str) and image.endswith(".encrypted"):
|
||||
# 加密数据由解密逻辑处理
|
||||
# 检查加密文件是否存在
|
||||
if image.endswith(".encrypted"):
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
return f"Invalid encrypted image file: {image}"
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
return f"Invalid encrypted image file: {image}"
|
||||
return True
|
||||
|
||||
if not folder_paths.exists_annotated_filepath(image):
|
||||
|
||||
+33
-56
@@ -9,14 +9,22 @@ import type {
|
||||
} from "@comfyorg/comfyui-frontend-types";
|
||||
import type { CryptIONode, CryptIOApp } from "./types.js";
|
||||
|
||||
import { loadEncryptedImageFromParams, downloadDecryptedImage } from "./utils/imageLoader.js";
|
||||
import { downloadDecryptedImage } from "./utils/imageLoader.js";
|
||||
import { syncSWStatus } from "./utils/swSync.js";
|
||||
|
||||
const app: CryptIOApp = rawApp as any;
|
||||
const api: ComfyApi = rawApi;
|
||||
|
||||
// 注册扩展
|
||||
// ──────────────────────────────────────────────────
|
||||
// Node extension
|
||||
// ──────────────────────────────────────────────────
|
||||
app.registerExtension({
|
||||
name: "cryptio.SaveImageCryptIO",
|
||||
|
||||
async setup() {
|
||||
await syncSWStatus();
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(
|
||||
nodeType: any,
|
||||
nodeData: ComfyNodeDef,
|
||||
@@ -26,54 +34,31 @@ app.registerExtension({
|
||||
// 覆盖默认的图片处理
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (this: CryptIONode, message: any) {
|
||||
// 调用原始处理
|
||||
// 调用原始处理 (populates this.imgs from message.images — SW解密透明)
|
||||
if (onExecuted) {
|
||||
onExecuted.apply(this, arguments);
|
||||
}
|
||||
|
||||
// 处理加密图片
|
||||
// Auto-download: fetch the /view URL directly, SW decrypts transparently
|
||||
if (message?.cryptio_images) {
|
||||
const autoDownload = this.widgets.find((n: any) => n.name === "auto_download")?.value;
|
||||
|
||||
// Revoke previous blob URLs to prevent memory leaks
|
||||
if (this._cryptioBlobUrls) {
|
||||
for (const url of this._cryptioBlobUrls) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
this._cryptioBlobUrls = [] as string[];
|
||||
|
||||
for (let i = 0; i < message.cryptio_images.length; i++) {
|
||||
const imageInfo = message.cryptio_images[i];
|
||||
if (imageInfo.filename && imageInfo.filename.endsWith(".encrypted")) {
|
||||
// 异步加载并解密图片
|
||||
loadEncryptedImageFromParams(api, imageInfo)
|
||||
.then((imageUrl) => {
|
||||
if (this._cryptioBlobUrls) {
|
||||
this._cryptioBlobUrls.push(imageUrl);
|
||||
}
|
||||
// 更新节点的图片显示
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!this.imgs) {
|
||||
this.imgs = [];
|
||||
}
|
||||
this.imgs[i] = img;
|
||||
if (app.rootGraph) {
|
||||
app.rootGraph.setDirtyCanvas(true, false);
|
||||
}
|
||||
|
||||
// 如果开启了自动下载
|
||||
if (autoDownload) {
|
||||
downloadDecryptedImage(imageUrl, imageInfo.filename);
|
||||
console.log(`Auto-downloaded decrypted image: ${imageInfo.filename.replace(/\.encrypted$/, "")}`);
|
||||
}
|
||||
};
|
||||
img.src = imageUrl;
|
||||
if (autoDownload && imageInfo.filename && imageInfo.filename.endsWith(".encrypted")) {
|
||||
const params = new URLSearchParams(imageInfo);
|
||||
const url = api.apiURL(`/view?${params}`);
|
||||
fetch(url)
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
downloadDecryptedImage(downloadUrl, imageInfo.filename);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
console.log(
|
||||
"[CryptIO] Auto-downloaded:", imageInfo.filename.replace(/\.encrypted$/, "")
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to decrypt image for preview:", error);
|
||||
});
|
||||
.catch((err) => console.error("[CryptIO] Auto-download failed:", err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,7 +71,6 @@ app.registerExtension({
|
||||
getExtraMenuOptions.apply(this, arguments);
|
||||
}
|
||||
|
||||
// 如果节点有图片
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
options.push({
|
||||
content: "下载解密图片",
|
||||
@@ -94,10 +78,16 @@ app.registerExtension({
|
||||
if (this.imgs) {
|
||||
for (let i = 0; i < this.imgs.length; i++) {
|
||||
const img = this.imgs[i];
|
||||
// 获取原始文件名
|
||||
const filename = this.images?.[i]?.filename || `image_${i}.png.encrypted`;
|
||||
if (img.src) {
|
||||
downloadDecryptedImage(img.src, filename);
|
||||
fetch(img.src)
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
downloadDecryptedImage(url, filename);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch((err) => console.error("[CryptIO] Download failed:", err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,19 +95,6 @@ app.registerExtension({
|
||||
});
|
||||
}
|
||||
};
|
||||
// Cleanup blob URLs on node removal
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function (this: CryptIONode) {
|
||||
if (this._cryptioBlobUrls) {
|
||||
for (const url of this._cryptioBlobUrls) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
this._cryptioBlobUrls = [];
|
||||
}
|
||||
if (onRemoved) {
|
||||
onRemoved.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CLIENT_KEYPAIR_STORAGE_KEY,
|
||||
SERVER_KEYS_STORAGE_KEY,
|
||||
} from "./utils/cryptoKeys.js";
|
||||
import { syncSWStatus, clearSWKeys } from "./utils/swSync.js";
|
||||
|
||||
const app: ComfyApp = rawApp;
|
||||
|
||||
@@ -239,6 +240,7 @@ async function handleGenerate(): Promise<void> {
|
||||
const { generateClientKeyPair } = await import("./utils/cryptoKeys.js");
|
||||
const keyPair = await generateClientKeyPair();
|
||||
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(keyPair));
|
||||
await syncSWStatus();
|
||||
showToast("New client keys generated", "success");
|
||||
} catch (error) {
|
||||
showToast(`Failed to generate keys: ${error}`, "error");
|
||||
@@ -254,6 +256,7 @@ async function handleClear(): Promise<void> {
|
||||
|
||||
localStorage.removeItem(CLIENT_KEYPAIR_STORAGE_KEY);
|
||||
localStorage.removeItem(SERVER_KEYS_STORAGE_KEY);
|
||||
clearSWKeys();
|
||||
showToast("All keys cleared", "success");
|
||||
}
|
||||
|
||||
@@ -284,6 +287,7 @@ async function handleUploadClient(event: Event): Promise<void> {
|
||||
const data = await readJSONFile(file);
|
||||
if (data.publicKey && data.privateKey) {
|
||||
localStorage.setItem(CLIENT_KEYPAIR_STORAGE_KEY, JSON.stringify(data));
|
||||
await syncSWStatus();
|
||||
showToast("Client keys uploaded", "success");
|
||||
} else {
|
||||
showToast("Invalid key format — must contain publicKey and privateKey", "error");
|
||||
@@ -300,6 +304,7 @@ async function handleUploadServer(event: Event): Promise<void> {
|
||||
const data = await readJSONFile(file);
|
||||
if (data.publicKey && data.privateKey) {
|
||||
localStorage.setItem(SERVER_KEYS_STORAGE_KEY, JSON.stringify(data));
|
||||
await syncSWStatus();
|
||||
showToast("Server keys uploaded", "success");
|
||||
} else {
|
||||
showToast("Invalid key format — must contain publicKey and privateKey", "error");
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CryptIONode, CryptIOApp } from "./types.js";
|
||||
|
||||
import { exchangeKeys } from "./utils/cryptoKeys.js";
|
||||
import { handleFileUpload, createPreviewUpdateFunction } from "./utils/uploadUtils.js";
|
||||
import { syncSWStatus } from "./utils/swSync.js";
|
||||
|
||||
const app: CryptIOApp = rawApp as any;
|
||||
const api: ComfyApi = rawApi;
|
||||
@@ -23,6 +24,7 @@ app.registerExtension({
|
||||
try {
|
||||
await exchangeKeys();
|
||||
console.log("CryptIO: Keys exchanged successfully");
|
||||
await syncSWStatus();
|
||||
} catch (error) {
|
||||
console.error("CryptIO: Failed to exchange keys:", error);
|
||||
}
|
||||
@@ -146,13 +148,9 @@ app.registerExtension({
|
||||
return r;
|
||||
};
|
||||
|
||||
// Cleanup blob URLs on node removal
|
||||
// Cleanup on node removal
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function (this: CryptIONode) {
|
||||
if (this._cryptioPreviewUrl) {
|
||||
URL.revokeObjectURL(this._cryptioPreviewUrl);
|
||||
this._cryptioPreviewUrl = null;
|
||||
}
|
||||
if (onRemoved) {
|
||||
onRemoved.apply(this, arguments);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
import type { CryptIONode, VideoPreviewWidget, CryptIOApp } from "./types.js";
|
||||
|
||||
import { exchangeKeys } from "./utils/cryptoKeys.js";
|
||||
import { loadEncryptedVideoFromFilename } from "./utils/videoLoader.js";
|
||||
import { uploadEncryptedFile } from "./utils/uploadUtils.js";
|
||||
import { syncSWStatus } from "./utils/swSync.js";
|
||||
import {
|
||||
createVideoWidget,
|
||||
renderVideoInWidget,
|
||||
@@ -22,11 +22,22 @@ const app: CryptIOApp = rawApp as any;
|
||||
const api: ComfyApi = rawApi;
|
||||
|
||||
/**
|
||||
* Load encrypted video from filename and render in widget
|
||||
* Load encrypted video from filename and render in widget.
|
||||
* SW intercepts /view?*.encrypted → decrypts transparently with server key.
|
||||
*/
|
||||
async function updateVideoWidget(widget: VideoPreviewWidget, filename: string) {
|
||||
const videoUrl = await loadEncryptedVideoFromFilename(filename);
|
||||
await renderVideoInWidget(widget, videoUrl);
|
||||
const url = `/api/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
try {
|
||||
await renderVideoInWidget(widget, url);
|
||||
} catch {
|
||||
console.warn("[CryptIO] Video preview load failed, syncing SW and retrying...");
|
||||
try {
|
||||
await syncSWStatus();
|
||||
await renderVideoInWidget(widget, url);
|
||||
} catch (err) {
|
||||
console.error("[CryptIO] Video preview failed after sync:", filename, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +81,7 @@ app.registerExtension({
|
||||
try {
|
||||
await exchangeKeys();
|
||||
console.log("CryptIO: Keys exchanged successfully for video upload");
|
||||
await syncSWStatus();
|
||||
} catch (error) {
|
||||
console.error("CryptIO: Failed to exchange keys:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// @ts-nocheck
|
||||
// CryptIO Service Worker — transparently decrypts *.encrypted files
|
||||
// served via /view?filename=... so both Vue ImagePreview and legacy
|
||||
// <img> / fetch() get plaintext without any frontend DOM hacks.
|
||||
//
|
||||
// Self-contained — no external imports (avoids SW path resolution issues).
|
||||
|
||||
const CRYPTO: Crypto = self.crypto as any;
|
||||
const LOG = (...args: any[]) => console.log("[CryptIO-SW]", ...args);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Base64 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = self.atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PEM → CryptoKey (RSA-OAEP private key, pkcs8) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function importPrivateKeyFromPem(pem: string): Promise<CryptoKey> {
|
||||
const header = "-----BEGIN PRIVATE KEY-----";
|
||||
const footer = "-----END PRIVATE KEY-----";
|
||||
const contents = pem
|
||||
.substring(pem.indexOf(header) + header.length, pem.indexOf(footer))
|
||||
.replace(/\s/g, "");
|
||||
const der = base64ToBytes(contents);
|
||||
return CRYPTO.subtle.importKey("pkcs8", der, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hybrid decrypt: RSA-OAEP unwrap AES key → AES-GCM decrypt */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function decryptData(encrypted: Uint8Array, privateKeyPem: string): Promise<Uint8Array> {
|
||||
// Wire format: [4B encKeyLen][encKey][4B ivLen][iv][ciphertext]
|
||||
const view = new DataView(encrypted.buffer, encrypted.byteOffset, encrypted.byteLength);
|
||||
let offset = 0;
|
||||
|
||||
const encKeyLen = view.getUint32(offset, false); offset += 4;
|
||||
const encKey = encrypted.slice(offset, offset + encKeyLen); offset += encKeyLen;
|
||||
|
||||
const ivLen = view.getUint32(offset, false); offset += 4;
|
||||
const iv = encrypted.slice(offset, offset + ivLen); offset += ivLen;
|
||||
|
||||
const ciphertext = encrypted.slice(offset);
|
||||
|
||||
const privateKey = await importPrivateKeyFromPem(privateKeyPem);
|
||||
const aesKeyBuf = await CRYPTO.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, encKey);
|
||||
const aesKey = await CRYPTO.subtle.importKey("raw", aesKeyBuf, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
|
||||
const plaintext = await CRYPTO.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext);
|
||||
return new Uint8Array(plaintext);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MIME helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function getRealExt(urlOrFilename: string): string {
|
||||
// Extract filename from URL query if present
|
||||
const url = new URL(urlOrFilename);
|
||||
const filename = url.searchParams.get("filename") || url.pathname.split("/").pop() || urlOrFilename;
|
||||
const parts = filename.split(".");
|
||||
if (parts.length >= 2 && parts[parts.length - 1] === "encrypted") return parts[parts.length - 2];
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function getMimeType(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
||||
webp: "image/webp", avif: "image/avif", gif: "image/gif",
|
||||
svg: "image/svg+xml", bmp: "image/bmp",
|
||||
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
|
||||
};
|
||||
return map[ext.toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SHA-256 fingerprint */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function sha256(str: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(str);
|
||||
const hashBuffer = await CRYPTO.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* IndexedDB persistence */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const DB_NAME = "cryptio-sw-store";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "keys";
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
req.result.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadKeysFromDB(): Promise<{ client: string | null; clientFp: string | null; server: string | null; serverFp: string | null }> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const getClient = store.get("clientKey");
|
||||
const getClientFp = store.get("clientFingerprint");
|
||||
const getServer = store.get("serverKey");
|
||||
const getServerFp = store.get("serverFingerprint");
|
||||
|
||||
const result = { client: null as string | null, clientFp: null as string | null, server: null as string | null, serverFp: null as string | null };
|
||||
getClient.onsuccess = () => { if (getClient.result) result.client = getClient.result; };
|
||||
getClientFp.onsuccess = () => { if (getClientFp.result) result.clientFp = getClientFp.result; };
|
||||
getServer.onsuccess = () => { if (getServer.result) result.server = getServer.result; };
|
||||
getServerFp.onsuccess = () => { if (getServerFp.result) result.serverFp = getServerFp.result; };
|
||||
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch {
|
||||
return { client: null, clientFp: null, server: null, serverFp: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKeyToDB(key: string, keyName: string, fp: string, fpName: string): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.put(key, keyName);
|
||||
store.put(fp, fpName);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch { /* ignore — IndexedDB unavailable */ }
|
||||
}
|
||||
|
||||
async function clearKeysFromDB(): Promise<void> {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
store.clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* State */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let clientPrivateKeyPem: string | null = null;
|
||||
let clientFingerprint: string | null = null;
|
||||
let serverPrivateKeyPem: string | null = null;
|
||||
let serverFingerprint: string | null = null;
|
||||
|
||||
function selectKey(url: string): string | null {
|
||||
// type=input → uploaded images encrypted with SERVER public key → server private key only
|
||||
// type=output / type=temp → save/preview encrypted with CLIENT public key → client private key
|
||||
if (url.includes("type=input")) return serverPrivateKeyPem;
|
||||
return clientPrivateKeyPem;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
(self as any).addEventListener("install", (event: ExtendableEvent) => {
|
||||
LOG("install — skipping waiting");
|
||||
(self as any).skipWaiting();
|
||||
});
|
||||
|
||||
(self as any).addEventListener("activate", (event: ExtendableEvent) => {
|
||||
LOG("activate — loading keys from IndexedDB + claiming clients");
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const stored = await loadKeysFromDB();
|
||||
if (stored.client) {
|
||||
clientPrivateKeyPem = stored.client;
|
||||
clientFingerprint = stored.clientFp || null;
|
||||
}
|
||||
if (stored.server) {
|
||||
serverPrivateKeyPem = stored.server;
|
||||
serverFingerprint = stored.serverFp || null;
|
||||
}
|
||||
LOG("keys loaded from DB — client:", !!clientPrivateKeyPem, "server:", !!serverPrivateKeyPem);
|
||||
} catch (err) {
|
||||
LOG("loadKeysFromDB failed:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
await (self as any).clients.claim();
|
||||
const clients = await (self as any).clients.matchAll();
|
||||
LOG("claim OK, clients in scope:", clients.length);
|
||||
} catch (err) {
|
||||
LOG("claim ERROR:", err);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Messages from main thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
self.addEventListener("message", (event: ExtendableEvent & { data?: any; ports?: MessagePort[] }) => {
|
||||
const msg = event.data;
|
||||
|
||||
if (msg?.type === "cryptio-set-key") {
|
||||
LOG("set-key received, fingerprint:", msg.fingerprint?.substring(0, 8) + "...");
|
||||
clientPrivateKeyPem = msg.key as string;
|
||||
clientFingerprint = msg.fingerprint || null;
|
||||
saveKeyToDB(msg.key, "clientKey", msg.fingerprint || "", "clientFingerprint").catch(() => {});
|
||||
if (event.ports[0]) {
|
||||
event.ports[0].postMessage({ type: "cryptio-set-key-ack", ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (msg?.type === "cryptio-set-server-key") {
|
||||
LOG("set-server-key received, fingerprint:", msg.fingerprint?.substring(0, 8) + "...");
|
||||
serverPrivateKeyPem = msg.key as string;
|
||||
serverFingerprint = msg.fingerprint || null;
|
||||
saveKeyToDB(msg.key, "serverKey", msg.fingerprint || "", "serverFingerprint").catch(() => {});
|
||||
if (event.ports[0]) {
|
||||
event.ports[0].postMessage({ type: "cryptio-set-server-key-ack", ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (msg?.type === "cryptio-sync-status") {
|
||||
const port = event.ports[0];
|
||||
if (port) {
|
||||
(async () => {
|
||||
const cfp = clientPrivateKeyPem ? (clientFingerprint || await sha256(clientPrivateKeyPem)) : null;
|
||||
const sfp = serverPrivateKeyPem ? (serverFingerprint || await sha256(serverPrivateKeyPem)) : null;
|
||||
port.postMessage({
|
||||
type: "cryptio-status",
|
||||
ready: true,
|
||||
interceptEnabled: true,
|
||||
keys: {
|
||||
client: { loaded: !!clientPrivateKeyPem, fingerprint: cfp },
|
||||
server: { loaded: !!serverPrivateKeyPem, fingerprint: sfp },
|
||||
},
|
||||
lastError: (self as any).__lastError || null,
|
||||
});
|
||||
})().catch((err) => {
|
||||
port.postMessage({ type: "cryptio-status", ready: false, interceptEnabled: false, keys: { client: { loaded: false, fingerprint: null }, server: { loaded: false, fingerprint: null } }, lastError: String(err) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (msg?.type === "cryptio-clear-keys") {
|
||||
LOG("clear-keys received");
|
||||
clientPrivateKeyPem = null;
|
||||
clientFingerprint = null;
|
||||
serverPrivateKeyPem = null;
|
||||
serverFingerprint = null;
|
||||
clearKeysFromDB().catch(() => {});
|
||||
if (event.ports[0]) {
|
||||
event.ports[0].postMessage({ type: "cryptio-clear-keys-ack", ok: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Intercept /view?*.encrypted → decrypt transparently */
|
||||
/* ------------------------------------------------------------------ */
|
||||
self.addEventListener("fetch", (event: FetchEvent) => {
|
||||
const url = event.request.url;
|
||||
|
||||
if (!url.includes("/view?") && !url.includes("/api/view?")) return;
|
||||
if (!url.includes(".encrypted")) return;
|
||||
|
||||
LOG("MATCH:", url);
|
||||
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
// Fetch original encrypted response (does not re-trigger SW)
|
||||
const response = await fetch(event.request);
|
||||
|
||||
const key = selectKey(url);
|
||||
if (!key) {
|
||||
LOG("no matching key, passing through encrypted response");
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const clone = response.clone();
|
||||
const encrypted = new Uint8Array(await clone.arrayBuffer());
|
||||
LOG("decrypting", encrypted.length, "bytes...");
|
||||
const decrypted = await decryptData(encrypted, key);
|
||||
const ext = getRealExt(url);
|
||||
const mime = getMimeType(ext);
|
||||
LOG("decrypt OK —", decrypted.length, "bytes, mime:", mime);
|
||||
|
||||
return new Response(decrypted, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "no-store",
|
||||
"X-CryptIO-Decrypted": "1",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
(self as any).__lastError = String(err);
|
||||
LOG("decrypt failed:", err);
|
||||
return response;
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
LOG("initialised, waiting for sync...");
|
||||
+13
@@ -35,6 +35,19 @@ export interface CryptIONode {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Worker status reported via sync message.
|
||||
*/
|
||||
export interface SWStatus {
|
||||
ready: boolean;
|
||||
interceptEnabled: boolean;
|
||||
keys: {
|
||||
client: { loaded: boolean; fingerprint: string | null };
|
||||
server: { loaded: boolean; fingerprint: string | null };
|
||||
};
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom properties for Video Preview widgets.
|
||||
*/
|
||||
|
||||
@@ -64,11 +64,17 @@ export async function loadEncryptedImageFromParams(api: ComfyApi, params: ImageL
|
||||
}
|
||||
|
||||
// 直接读取二进制数据
|
||||
let decryptedData: Uint8Array;
|
||||
const encryptedArrayBuffer = await response.arrayBuffer();
|
||||
const encryptedData = new Uint8Array(encryptedArrayBuffer);
|
||||
|
||||
// 使用客户端私钥解密(SaveImage/PreviewImage场景)
|
||||
const decryptedData = await decryptFileWithClientKey(encryptedData);
|
||||
// If the Service Worker already decrypted the response, skip re-decryption
|
||||
if (response.headers.get("X-CryptIO-Decrypted") === "1") {
|
||||
decryptedData = encryptedData;
|
||||
} else {
|
||||
// 使用客户端私钥解密(SaveImage/PreviewImage场景)
|
||||
decryptedData = await decryptFileWithClientKey(encryptedData);
|
||||
}
|
||||
|
||||
// 转换为Blob URL
|
||||
const blob = new Blob([decryptedData as BlobPart], {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* SW status sync module — single source of truth for Service Worker
|
||||
* registration, key delivery, and consistency verification.
|
||||
*
|
||||
* Replacements from the old swKeySender.ts:
|
||||
* sendKeysToServiceWorker() → syncSWStatus()
|
||||
* (nothing) → clearSWKeys()
|
||||
*/
|
||||
import { getClientKeyPair, getServerKeysWithExchange } from "./cryptoKeys.js";
|
||||
|
||||
let _syncPromise: Promise<void> | null = null;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SHA-256 fingerprint */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export async function sha256(str: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(str);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Find / register the CryptIO Service Worker */
|
||||
/* ------------------------------------------------------------------ */
|
||||
async function ensureSWReady(): Promise<ServiceWorker | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
|
||||
const SW_PATH = "/cryptio-sw.js";
|
||||
|
||||
// Check if already registered
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
const existing = regs.find((r) => {
|
||||
const script = r.active?.scriptURL || r.waiting?.scriptURL || r.installing?.scriptURL || "";
|
||||
return script.endsWith(SW_PATH);
|
||||
});
|
||||
|
||||
let registration: ServiceWorkerRegistration;
|
||||
if (existing) {
|
||||
registration = existing;
|
||||
} else {
|
||||
registration = await navigator.serviceWorker.register(SW_PATH, { scope: "/" });
|
||||
console.log("[CryptIO] SW registered:", registration.active?.scriptURL || registration.installing?.scriptURL);
|
||||
}
|
||||
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Wait for the SW to become the controller
|
||||
for (let i = 0; i < 20 && (!navigator.serviceWorker.controller || !navigator.serviceWorker.controller.scriptURL.includes("cryptio")); i++) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
if (!controller || !controller.scriptURL.includes("cryptio")) {
|
||||
console.warn("[CryptIO] SW not controlling page after wait — controllerchange listener will retry");
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log("[CryptIO] SW controlling:", controller.scriptURL);
|
||||
return controller;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Query SW status via MessageChannel */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function querySWStatus(sw: ServiceWorker): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => {
|
||||
channel.port1.close();
|
||||
reject(new Error("SW status query timed out"));
|
||||
}, 5000);
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.type === "cryptio-status") {
|
||||
resolve(event.data);
|
||||
} else {
|
||||
reject(new Error("Unexpected SW response: " + JSON.stringify(event.data)));
|
||||
}
|
||||
};
|
||||
|
||||
sw.postMessage({ type: "cryptio-sync-status" }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Send a single key to SW (fire-and-forget) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function sendKeyToSW(sw: ServiceWorker, type: string, key: string, fingerprint: string): void {
|
||||
sw.postMessage({ type, key, fingerprint });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* syncSWStatus — ensure SW is ready and holds consistent keys */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export async function syncSWStatus(): Promise<void> {
|
||||
if (_syncPromise) return _syncPromise;
|
||||
_syncPromise = _doSync();
|
||||
try {
|
||||
await _syncPromise;
|
||||
} finally {
|
||||
_syncPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _doSync(): Promise<void> {
|
||||
const sw = await ensureSWReady();
|
||||
if (!sw) {
|
||||
console.warn("[CryptIO] sync skipped — SW not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read main-thread keys + fingerprints
|
||||
const clientKP = await getClientKeyPair();
|
||||
const serverKeys = await getServerKeysWithExchange();
|
||||
const expectedClientFp = await sha256(clientKP.privateKey);
|
||||
const expectedServerFp = await sha256(serverKeys.privateKey);
|
||||
|
||||
// Query SW current state
|
||||
const status = await querySWStatus(sw);
|
||||
console.log("[CryptIO] SW status — client:", status.keys.client, "server:", status.keys.server);
|
||||
|
||||
// Push keys that are missing or mismatched
|
||||
let updated = false;
|
||||
if (!status.keys.client.loaded || status.keys.client.fingerprint !== expectedClientFp) {
|
||||
console.log("[CryptIO] pushing client key to SW...");
|
||||
sendKeyToSW(sw, "cryptio-set-key", clientKP.privateKey, expectedClientFp);
|
||||
updated = true;
|
||||
}
|
||||
if (!status.keys.server.loaded || status.keys.server.fingerprint !== expectedServerFp) {
|
||||
console.log("[CryptIO] pushing server key to SW...");
|
||||
sendKeyToSW(sw, "cryptio-set-server-key", serverKeys.privateKey, expectedServerFp);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
// Wait for SW to process keys, then verify
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
const finalStatus = await querySWStatus(sw);
|
||||
const clientOk = finalStatus.keys.client.loaded && finalStatus.keys.client.fingerprint === expectedClientFp;
|
||||
const serverOk = finalStatus.keys.server.loaded && finalStatus.keys.server.fingerprint === expectedServerFp;
|
||||
if (clientOk && serverOk) {
|
||||
console.log("[CryptIO] sync verified — keys consistent");
|
||||
} else {
|
||||
console.warn("[CryptIO] sync verification mismatch — client:", clientOk, "server:", serverOk);
|
||||
}
|
||||
} else {
|
||||
console.log("[CryptIO] sync skipped — keys already consistent");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* clearSWKeys — clear all keys from SW memory + IndexedDB */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export async function clearSWKeys(): Promise<void> {
|
||||
const sw = navigator.serviceWorker?.controller;
|
||||
if (!sw || !sw.scriptURL.includes("cryptio")) {
|
||||
console.warn("[CryptIO] clearSWKeys skipped — no cryptio SW controlling");
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => {
|
||||
channel.port1.close();
|
||||
console.warn("[CryptIO] clearSWKeys timed out");
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
channel.port1.onmessage = () => {
|
||||
clearTimeout(timeout);
|
||||
console.log("[CryptIO] SW keys cleared");
|
||||
resolve();
|
||||
};
|
||||
|
||||
sw.postMessage({ type: "cryptio-clear-keys" }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* sendKeysToServiceWorker — backward-compat wrapper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export async function sendKeysToServiceWorker(): Promise<void> {
|
||||
await syncSWStatus();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Controller change listener (set once at module load) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
let _listenerSetup = false;
|
||||
if (!_listenerSetup && typeof navigator !== "undefined" && "serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
console.log("[CryptIO] controllerchange — re-syncing status");
|
||||
_syncPromise = null;
|
||||
syncSWStatus().catch((err) => console.warn("[CryptIO] controllerchange sync failed:", err));
|
||||
});
|
||||
_listenerSetup = true;
|
||||
}
|
||||
+22
-22
@@ -6,7 +6,7 @@
|
||||
import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
|
||||
import type { CryptIONode } from "../types.js";
|
||||
import { encryptFileWithServerKey } from "./cryptoUtils.js";
|
||||
import { loadEncryptedImageFromFilename } from "./imageLoader.js";
|
||||
import { syncSWStatus } from "./swSync.js";
|
||||
|
||||
/**
|
||||
* Upload an encrypted file to the server (encrypted with server public key).
|
||||
@@ -68,8 +68,6 @@ export async function handleFileUpload(node: CryptIONode, file: File, app: Comfy
|
||||
if (node.updatePreview) {
|
||||
await node.updatePreview(result.name);
|
||||
}
|
||||
|
||||
app.rootGraph?.setDirtyCanvas(true, false);
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
alert("Failed to upload encrypted image: " + error);
|
||||
@@ -83,28 +81,30 @@ export async function handleFileUpload(node: CryptIONode, file: File, app: Comfy
|
||||
*/
|
||||
export function createPreviewUpdateFunction(app: ComfyApp) {
|
||||
return async function (this: CryptIONode, filename: string) {
|
||||
if (!filename || !filename.endsWith(".encrypted")) {
|
||||
return;
|
||||
}
|
||||
if (!filename || !filename.endsWith(".encrypted")) return;
|
||||
|
||||
const url = `/api/view?filename=${encodeURIComponent(filename)}&type=input`;
|
||||
|
||||
const loadImage = (): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error(`Failed to load: ${filename}`));
|
||||
i.src = url;
|
||||
});
|
||||
|
||||
try {
|
||||
// Revoke previous blob URL to prevent memory leak
|
||||
if (this._cryptioPreviewUrl) {
|
||||
URL.revokeObjectURL(this._cryptioPreviewUrl);
|
||||
}
|
||||
|
||||
const imageUrl = await loadEncryptedImageFromFilename(filename);
|
||||
this._cryptioPreviewUrl = imageUrl;
|
||||
|
||||
// 更新节点的图片显示
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this.imgs = [img];
|
||||
this.imgs = [await loadImage()];
|
||||
app.rootGraph?.setDirtyCanvas(true, false);
|
||||
} catch {
|
||||
console.warn("[CryptIO] Preview load failed, syncing SW status and retrying...");
|
||||
try {
|
||||
await syncSWStatus();
|
||||
this.imgs = [await loadImage()];
|
||||
app.rootGraph?.setDirtyCanvas(true, false);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
} catch (error) {
|
||||
console.error("Preview error:", error);
|
||||
} catch (err) {
|
||||
console.error("[CryptIO] Preview failed after sync:", filename, err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,11 +64,17 @@ export async function loadEncryptedVideoFromParams(api: ComfyApi, params: VideoL
|
||||
}
|
||||
|
||||
// 直接读取二进制数据
|
||||
let decryptedData: Uint8Array;
|
||||
const encryptedArrayBuffer = await response.arrayBuffer();
|
||||
const encryptedData = new Uint8Array(encryptedArrayBuffer);
|
||||
|
||||
// 使用客户端私钥解密(SaveVideo/PreviewVideo场景)
|
||||
const decryptedData = await decryptFileWithClientKey(encryptedData);
|
||||
// If the Service Worker already decrypted the response, skip re-decryption
|
||||
if (response.headers.get("X-CryptIO-Decrypted") === "1") {
|
||||
decryptedData = encryptedData;
|
||||
} else {
|
||||
// 使用客户端私钥解密(SaveVideo/PreviewVideo场景)
|
||||
decryptedData = await decryptFileWithClientKey(encryptedData);
|
||||
}
|
||||
|
||||
// 转换为Blob URL
|
||||
const blob = new Blob([new Uint8Array(decryptedData)], {
|
||||
|
||||
Reference in New Issue
Block a user