From 64cf9c330db808d330b598e5854a3961a12726f3 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Thu, 18 Jun 2026 21:41:56 +0800 Subject: [PATCH] refactor(share): remove GALLERY_SECRET, use plaintext + SHA-256 integrity for .infiplot files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypted .infiplot format (AES-256-GCM via GALLERY_SECRET) provided no meaningful security — the payload is AI-generated story content with no credentials or PII, and the project is open source. Replace with plaintext + SHA-256 integrity check (format v2). Story share is now always enabled without requiring a server secret. - galleryCrypto.ts: AES-256-GCM → plaintext + SHA-256 hash; remove secret param - 4 API routes: remove GALLERY_SECRET guard and 503 fallback - story-unpack: forward specific error messages (v1 compat, hash mismatch) - gallery/page.tsx: remove stale AES-GCM comment - AGENTS.md: document gallery-pack/gallery-unpack routes - .env.example, wrangler.jsonc: remove GALLERY_SECRET references Co-Authored-By: Claude Opus 4.6 --- .env.example | 14 ++--- AGENTS.md | 5 +- app/api/gallery-pack/route.ts | 16 +---- app/api/gallery-unpack/route.ts | 18 +----- app/api/story-pack/route.ts | 10 +-- app/api/story-unpack/route.ts | 14 +---- app/gallery/page.tsx | 5 -- lib/galleryCrypto.ts | 107 ++++++++++---------------------- wrangler.jsonc | 2 +- 9 files changed, 48 insertions(+), 143 deletions(-) diff --git a/.env.example b/.env.example index ec049e0..2f3657e 100644 --- a/.env.example +++ b/.env.example @@ -155,16 +155,10 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID= # Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com NEXT_PUBLIC_UMAMI_DOMAINS= -# ---- 7. Gallery share files (optional — leave blank to disable) ---- -# Server-side secret used to AES-256-GCM encrypt a played session into a -# binary `.infiplot` share file the player can send to a friend. Friends drop -# the file into /gallery; the server decrypts and renders the same interactive -# replay. GCM's built-in auth tag also gives tamper-detection for free. -# Blank → "导出分享文件" is hidden, only the same-browser localStorage flow -# remains. Set to any high-entropy string ≥ 32 chars (e.g. `openssl rand -hex 32`). -# WARNING: rotating this secret invalidates every share file ever issued -# (decryption will fail with "文件校验失败"). Only change when you're OK with that. -GALLERY_SECRET= +# ---- 7. Gallery share files ---- +# Story share (`.infiplot` files) is always enabled — no secret needed. +# Files use SHA-256 integrity checks instead of encryption because the +# payload is AI-generated story content, not sensitive data. # ---- 8. Auth · Supabase (optional — leave blank to disable) ------- # Sign up at https://supabase.com, create a project, copy the URL and diff --git a/AGENTS.md b/AGENTS.md index ad05fa1..e83094c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,8 @@ Common routes live under `app/api/`: - `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent. `voice` is now OPTIONAL — when the server runs StepFun, the client omits the ~220KB Xiaomi reference audio and sends `stepfunVoiceId` / `voiceDescription` instead (saves Fast Origin Transfer bandwidth). The engine re-provisions on a provider mismatch before synthesizing. - `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art. - `GET /api/tts-provider`: returns `{ provider: "stepfun" | "xiaomi" | null }` (the server's TTS provider, inferred from `TTS_BASE_URL`). Probed once at `/play` mount (non-BYO) so `fetchBeatAudio` can shape its request body — skip the ~220KB Xiaomi reference audio when the server runs StepFun. BYO client TTS takes precedence over this signal. -- `POST /api/story-pack` / `POST /api/story-unpack`: stateless AES-GCM packing/unpacking for playable story share `.infiplot` files; uses `GALLERY_SECRET`. +- `POST /api/story-pack` / `POST /api/story-unpack`: stateless packing/unpacking for playable story share `.infiplot` files (plaintext + SHA-256 integrity check, no encryption). +- `POST /api/gallery-pack` / `POST /api/gallery-unpack`: same format as story-pack/unpack but for gallery share files (5 MB pack limit vs story's 12 MB). When changing public types or route payloads, update all route callers and client consumers in the same change. @@ -145,7 +146,7 @@ Use `.env.example` as the source of truth. Never commit `.env.local`, API keys, - `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration. - `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts. - Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving. -- `GALLERY_SECRET` enables encrypted `.infiplot` share files for gallery and playable story export/import. +- `.infiplot` share files use plaintext + SHA-256 integrity (no encryption, no secret needed); the feature is always enabled. - `NEXT_PUBLIC_*` values are inlined at build time. ## File Dependency Map diff --git a/app/api/gallery-pack/route.ts b/app/api/gallery-pack/route.ts index 818b65e..5477fed 100644 --- a/app/api/gallery-pack/route.ts +++ b/app/api/gallery-pack/route.ts @@ -4,18 +4,7 @@ export const runtime = "nodejs"; const MAX_DOC_BYTES = 5_000_000; -// Encrypt a gallery doc into the shareable `.infiplot` binary format. -// Stateless: input is the doc string, output is the encrypted bytes — server -// keeps nothing. The secret must be configured (no insecure fallback). export async function POST(req: Request): Promise { - const secret = process.env.GALLERY_SECRET; - if (!secret) { - return Response.json( - { error: "图集分享未启用 (GALLERY_SECRET 未配置)" }, - { status: 503 }, - ); - } - let docStr: string; try { const body = (await req.json()) as { docStr?: unknown }; @@ -34,10 +23,7 @@ export async function POST(req: Request): Promise { ); } - const bytes = await packDoc(docStr, secret); - // Copy into a fresh ArrayBuffer so TS 5.7's stricter BodyInit typing accepts - // it (Uint8Array.buffer is ArrayBufferLike, which the BodyInit overloads - // don't narrow). Cheap — one extra alloc + memcpy of ~50-200KB. + const bytes = await packDoc(docStr); const ab = new ArrayBuffer(bytes.byteLength); new Uint8Array(ab).set(bytes); return new Response(ab, { diff --git a/app/api/gallery-unpack/route.ts b/app/api/gallery-unpack/route.ts index 8e1703c..52a8b59 100644 --- a/app/api/gallery-unpack/route.ts +++ b/app/api/gallery-unpack/route.ts @@ -2,25 +2,9 @@ import { unpackDoc } from "@/lib/galleryCrypto"; export const runtime = "nodejs"; -// Cap a bit above pack's MAX_DOC_BYTES — ciphertext adds the 16-byte GCM tag -// and the 17-byte header; some slack accommodates near-cap docs without -// rejecting them at unpack time. Bumped to fit pre-baked beat audio. const MAX_FILE_BYTES = 13_000_000; -// Decrypt a `.infiplot` share file back to its doc JSON string. Returns the -// plaintext as a JSON field (not raw bytes) so the client can chain it through -// JSON.parse without sniffing the response type. Errors are deliberately -// generic — we don't distinguish "wrong key" from "tampered file" because the -// distinction would leak server config. export async function POST(req: Request): Promise { - const secret = process.env.GALLERY_SECRET; - if (!secret) { - return Response.json( - { error: "图集分享未启用 (GALLERY_SECRET 未配置)" }, - { status: 503 }, - ); - } - let ab: ArrayBuffer; try { ab = await req.arrayBuffer(); @@ -35,7 +19,7 @@ export async function POST(req: Request): Promise { } try { - const docStr = await unpackDoc(new Uint8Array(ab), secret); + const docStr = await unpackDoc(new Uint8Array(ab)); return Response.json({ docStr }); } catch (e) { return Response.json( diff --git a/app/api/story-pack/route.ts b/app/api/story-pack/route.ts index 29bb9de..c68b0ce 100644 --- a/app/api/story-pack/route.ts +++ b/app/api/story-pack/route.ts @@ -5,14 +5,6 @@ export const runtime = "nodejs"; const MAX_DOC_BYTES = 12_000_000; export async function POST(req: Request): Promise { - const secret = process.env.GALLERY_SECRET; - if (!secret) { - return Response.json( - { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, - { status: 503 }, - ); - } - const contentLength = req.headers.get("content-length"); if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) { return Response.json( @@ -39,7 +31,7 @@ export async function POST(req: Request): Promise { ); } - const bytes = await packDoc(docStr, secret); + const bytes = await packDoc(docStr); const ab = new ArrayBuffer(bytes.byteLength); new Uint8Array(ab).set(bytes); return new Response(ab, { diff --git a/app/api/story-unpack/route.ts b/app/api/story-unpack/route.ts index c0c2e68..2e35258 100644 --- a/app/api/story-unpack/route.ts +++ b/app/api/story-unpack/route.ts @@ -5,14 +5,6 @@ export const runtime = "nodejs"; const MAX_FILE_BYTES = 13_000_000; export async function POST(req: Request): Promise { - const secret = process.env.GALLERY_SECRET; - if (!secret) { - return Response.json( - { error: "剧情分享未启用 (GALLERY_SECRET 未配置)" }, - { status: 503 }, - ); - } - const contentLength = req.headers.get("content-length"); if (contentLength && Number(contentLength) > MAX_FILE_BYTES) { return Response.json({ error: "文件太大" }, { status: 413 }); @@ -32,11 +24,11 @@ export async function POST(req: Request): Promise { } try { - const docStr = await unpackDoc(new Uint8Array(ab), secret); + const docStr = await unpackDoc(new Uint8Array(ab)); return Response.json({ docStr }); - } catch { + } catch (e) { return Response.json( - { error: "剧情文件解包失败" }, + { error: e instanceof Error ? e.message : "剧情文件解包失败" }, { status: 400 }, ); } diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index b37556c..e68d151 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -954,11 +954,6 @@ function GalleryInner() { }, [doc, downloadingScenes]); // ── Import a friend-shared `.infiplot` file ────────────────────────── - // The file is AES-GCM ciphertext only this deployment can decrypt; we POST - // the raw bytes to /api/gallery-unpack and let the server hand us back the - // doc as a JSON string. GCM's auth tag means a tampered or wrong-key file - // surfaces as a 400 with a human-readable error here — no need to verify - // anything client-side. const loadDocFromFile = useCallback(async (file: File) => { setImporting(true); setImportError(null); diff --git a/lib/galleryCrypto.ts b/lib/galleryCrypto.ts index fe2bc2a..cf650d6 100644 --- a/lib/galleryCrypto.ts +++ b/lib/galleryCrypto.ts @@ -1,98 +1,59 @@ -// Gallery share-file crypto. AES-256-GCM via Web Crypto — same API in Node 22+ -// (`globalThis.crypto`) and Cloudflare Workers, so the `runtime = "nodejs"` -// routes still port cleanly to the OpenNext / Cloudflare build later. +// Gallery share-file packing. Plaintext + SHA-256 integrity check. +// Uses only Web Crypto (`globalThis.crypto`) so it works in both +// Node 22+ and Cloudflare Workers. // -// Threat model: -// - Confidentiality: scene URLs + dialogue stay opaque to a casual recipient -// who isn't going through our server (can't curl the file and grep prompts). -// - Integrity: GCM's built-in auth tag means flipping any byte in the -// ciphertext or nonce makes subtle.decrypt throw — no separate HMAC needed. -// - NOT a replay defense: anyone with a valid file can replay it forever -// (this is intentional — it's a share-with-a-friend file, not an auth token). -// -// File layout (all big-endian, raw bytes): +// File layout (raw bytes): // 0..3 "IFPL" magic — lets us refuse anything that's not ours -// 4 version (=1) bumped if the format ever changes -// 5..16 nonce (12 B) random per file; GCM requires non-repeating nonces -// per key (12-B random gives ~2^-32 collision risk at -// ~4B files — way more than this app will ever produce) -// 17.. ciphertext includes the 16-byte GCM auth tag at the end -// -// Key derivation: SHA-256 of the secret. We don't bother with HKDF/scrypt — -// the secret is already high-entropy (deployer-supplied 32+ char string), -// SHA-256 just normalizes it to AES-256's 32-byte key length. +// 4 version (=2) v1 was AES-256-GCM encrypted (removed) +// 5..36 SHA-256 (32 B) integrity hash of the plaintext +// 37.. plaintext raw UTF-8 JSON const MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL" -const VERSION = 1; -const NONCE_LEN = 12; -const HEADER_LEN = MAGIC.length + 1 + NONCE_LEN; +const VERSION = 2; +const HASH_LEN = 32; +const HEADER_LEN = MAGIC.length + 1 + HASH_LEN; // 37 -async function deriveKey(secret: string): Promise { - const material = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(secret), - ); - return crypto.subtle.importKey( - "raw", - material, - { name: "AES-GCM" }, - false, - ["encrypt", "decrypt"], - ); -} - -export async function packDoc( - docStr: string, - secret: string, -): Promise { - const key = await deriveKey(secret); - const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN)); +export async function packDoc(docStr: string): Promise { const plaintext = new TextEncoder().encode(docStr); - const ciphertext = new Uint8Array( - await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext), + const hash = new Uint8Array( + await crypto.subtle.digest("SHA-256", plaintext), ); - const out = new Uint8Array(HEADER_LEN + ciphertext.length); + const out = new Uint8Array(HEADER_LEN + plaintext.length); out.set(MAGIC, 0); out[MAGIC.length] = VERSION; - out.set(nonce, MAGIC.length + 1); - out.set(ciphertext, HEADER_LEN); + out.set(hash, MAGIC.length + 1); + out.set(plaintext, HEADER_LEN); return out; } -export async function unpackDoc( - blob: Uint8Array, - secret: string, -): Promise { - // 16 = minimum ciphertext length (auth tag alone, with empty plaintext) - if (blob.length < HEADER_LEN + 16) { - throw new Error("文件太小,不是合法的图集分享文件"); +export async function unpackDoc(blob: Uint8Array): Promise { + if (blob.length < HEADER_LEN) { + throw new Error("文件太小,不是合法的分享文件"); } for (let i = 0; i < MAGIC.length; i++) { if (blob[i] !== MAGIC[i]) { - throw new Error("文件格式不对,不是合法的图集分享文件"); + throw new Error("文件格式不对,不是合法的分享文件"); } } const version = blob[MAGIC.length]; + if (version === 1) { + throw new Error("此文件由旧版本加密导出,当前版本不再支持加密格式"); + } if (version !== VERSION) { - throw new Error(`图集分享文件版本不被支持: v${version}`); + throw new Error(`分享文件版本不被支持: v${version}`); } - const nonce = blob.slice(MAGIC.length + 1, HEADER_LEN); - const ciphertext = blob.slice(HEADER_LEN); - const key = await deriveKey(secret); - let plaintext: ArrayBuffer; - try { - plaintext = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: nonce }, - key, - ciphertext, - ); - } catch { - // GCM auth tag failure → decryption refuses. Maps tamper + wrong-key both - // here, which is the right behavior: we can't distinguish, and neither - // should leak more than "this file isn't for this server". - throw new Error("文件校验失败:可能被改动过,或来自另一台部署"); + const storedHash = blob.slice(MAGIC.length + 1, HEADER_LEN); + const plaintext = blob.slice(HEADER_LEN); + const computedHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", plaintext), + ); + + if (storedHash.length !== computedHash.length || + !storedHash.every((b, i) => b === computedHash[i])) { + throw new Error("文件校验失败:内容可能被改动过"); } + return new TextDecoder().decode(plaintext); } diff --git a/wrangler.jsonc b/wrangler.jsonc index 388671e..b75ab97 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -40,7 +40,7 @@ // ── Secrets (set via Dashboard or `wrangler secret put`) ───────────── // Required (3): TEXT_API_KEY, IMAGE_API_KEY, VISION_API_KEY - // Optional (2): TTS_API_KEY (voice synthesis), GALLERY_SECRET (story share encryption) + // Optional (1): TTS_API_KEY (voice synthesis) // // ── Runtime variables (set via Dashboard) ──────────────────────────── // Required (6): TEXT_BASE_URL, TEXT_MODEL, IMAGE_BASE_URL, IMAGE_MODEL,