refactor(share): remove GALLERY_SECRET, use plaintext + SHA-256 integrity for .infiplot files

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 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-18 21:41:56 +08:00
parent 03dccd7c74
commit 64cf9c330d
9 changed files with 48 additions and 143 deletions
+4 -10
View File
@@ -155,16 +155,10 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID=
# Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com # Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com
NEXT_PUBLIC_UMAMI_DOMAINS= NEXT_PUBLIC_UMAMI_DOMAINS=
# ---- 7. Gallery share files (optional — leave blank to disable) ---- # ---- 7. Gallery share files ----
# Server-side secret used to AES-256-GCM encrypt a played session into a # Story share (`.infiplot` files) is always enabled — no secret needed.
# binary `.infiplot` share file the player can send to a friend. Friends drop # Files use SHA-256 integrity checks instead of encryption because the
# the file into /gallery; the server decrypts and renders the same interactive # payload is AI-generated story content, not sensitive data.
# 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=
# ---- 8. Auth · Supabase (optional — leave blank to disable) ------- # ---- 8. Auth · Supabase (optional — leave blank to disable) -------
# Sign up at https://supabase.com, create a project, copy the URL and # Sign up at https://supabase.com, create a project, copy the URL and
+3 -2
View File
@@ -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/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. - `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. - `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. 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. - `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. - `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. - 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. - `NEXT_PUBLIC_*` values are inlined at build time.
## File Dependency Map ## File Dependency Map
+1 -15
View File
@@ -4,18 +4,7 @@ export const runtime = "nodejs";
const MAX_DOC_BYTES = 5_000_000; 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<Response> { export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "图集分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
let docStr: string; let docStr: string;
try { try {
const body = (await req.json()) as { docStr?: unknown }; const body = (await req.json()) as { docStr?: unknown };
@@ -34,10 +23,7 @@ export async function POST(req: Request): Promise<Response> {
); );
} }
const bytes = await packDoc(docStr, secret); const bytes = await packDoc(docStr);
// 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 ab = new ArrayBuffer(bytes.byteLength); const ab = new ArrayBuffer(bytes.byteLength);
new Uint8Array(ab).set(bytes); new Uint8Array(ab).set(bytes);
return new Response(ab, { return new Response(ab, {
+1 -17
View File
@@ -2,25 +2,9 @@ import { unpackDoc } from "@/lib/galleryCrypto";
export const runtime = "nodejs"; 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; 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<Response> { export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "图集分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
let ab: ArrayBuffer; let ab: ArrayBuffer;
try { try {
ab = await req.arrayBuffer(); ab = await req.arrayBuffer();
@@ -35,7 +19,7 @@ export async function POST(req: Request): Promise<Response> {
} }
try { try {
const docStr = await unpackDoc(new Uint8Array(ab), secret); const docStr = await unpackDoc(new Uint8Array(ab));
return Response.json({ docStr }); return Response.json({ docStr });
} catch (e) { } catch (e) {
return Response.json( return Response.json(
+1 -9
View File
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
const MAX_DOC_BYTES = 12_000_000; const MAX_DOC_BYTES = 12_000_000;
export async function POST(req: Request): Promise<Response> { export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
const contentLength = req.headers.get("content-length"); const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) { if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
return Response.json( return Response.json(
@@ -39,7 +31,7 @@ export async function POST(req: Request): Promise<Response> {
); );
} }
const bytes = await packDoc(docStr, secret); const bytes = await packDoc(docStr);
const ab = new ArrayBuffer(bytes.byteLength); const ab = new ArrayBuffer(bytes.byteLength);
new Uint8Array(ab).set(bytes); new Uint8Array(ab).set(bytes);
return new Response(ab, { return new Response(ab, {
+3 -11
View File
@@ -5,14 +5,6 @@ export const runtime = "nodejs";
const MAX_FILE_BYTES = 13_000_000; const MAX_FILE_BYTES = 13_000_000;
export async function POST(req: Request): Promise<Response> { export async function POST(req: Request): Promise<Response> {
const secret = process.env.GALLERY_SECRET;
if (!secret) {
return Response.json(
{ error: "剧情分享未启用 (GALLERY_SECRET 未配置)" },
{ status: 503 },
);
}
const contentLength = req.headers.get("content-length"); const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) { if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
return Response.json({ error: "文件太大" }, { status: 413 }); return Response.json({ error: "文件太大" }, { status: 413 });
@@ -32,11 +24,11 @@ export async function POST(req: Request): Promise<Response> {
} }
try { try {
const docStr = await unpackDoc(new Uint8Array(ab), secret); const docStr = await unpackDoc(new Uint8Array(ab));
return Response.json({ docStr }); return Response.json({ docStr });
} catch { } catch (e) {
return Response.json( return Response.json(
{ error: "剧情文件解包失败" }, { error: e instanceof Error ? e.message : "剧情文件解包失败" },
{ status: 400 }, { status: 400 },
); );
} }
-5
View File
@@ -954,11 +954,6 @@ function GalleryInner() {
}, [doc, downloadingScenes]); }, [doc, downloadingScenes]);
// ── Import a friend-shared `.infiplot` file ────────────────────────── // ── 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) => { const loadDocFromFile = useCallback(async (file: File) => {
setImporting(true); setImporting(true);
setImportError(null); setImportError(null);
+33 -72
View File
@@ -1,98 +1,59 @@
// Gallery share-file crypto. AES-256-GCM via Web Crypto — same API in Node 22+ // Gallery share-file packing. Plaintext + SHA-256 integrity check.
// (`globalThis.crypto`) and Cloudflare Workers, so the `runtime = "nodejs"` // Uses only Web Crypto (`globalThis.crypto`) so it works in both
// routes still port cleanly to the OpenNext / Cloudflare build later. // Node 22+ and Cloudflare Workers.
// //
// Threat model: // File layout (raw bytes):
// - 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):
// 0..3 "IFPL" magic — lets us refuse anything that's not ours // 0..3 "IFPL" magic — lets us refuse anything that's not ours
// 4 version (=1) bumped if the format ever changes // 4 version (=2) v1 was AES-256-GCM encrypted (removed)
// 5..16 nonce (12 B) random per file; GCM requires non-repeating nonces // 5..36 SHA-256 (32 B) integrity hash of the plaintext
// per key (12-B random gives ~2^-32 collision risk at // 37.. plaintext raw UTF-8 JSON
// ~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.
const MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL" const MAGIC = [0x49, 0x46, 0x50, 0x4c] as const; // "IFPL"
const VERSION = 1; const VERSION = 2;
const NONCE_LEN = 12; const HASH_LEN = 32;
const HEADER_LEN = MAGIC.length + 1 + NONCE_LEN; const HEADER_LEN = MAGIC.length + 1 + HASH_LEN; // 37
async function deriveKey(secret: string): Promise<CryptoKey> { export async function packDoc(docStr: string): Promise<Uint8Array> {
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<Uint8Array> {
const key = await deriveKey(secret);
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
const plaintext = new TextEncoder().encode(docStr); const plaintext = new TextEncoder().encode(docStr);
const ciphertext = new Uint8Array( const hash = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, plaintext), 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.set(MAGIC, 0);
out[MAGIC.length] = VERSION; out[MAGIC.length] = VERSION;
out.set(nonce, MAGIC.length + 1); out.set(hash, MAGIC.length + 1);
out.set(ciphertext, HEADER_LEN); out.set(plaintext, HEADER_LEN);
return out; return out;
} }
export async function unpackDoc( export async function unpackDoc(blob: Uint8Array): Promise<string> {
blob: Uint8Array, if (blob.length < HEADER_LEN) {
secret: string, throw new Error("文件太小,不是合法的分享文件");
): Promise<string> {
// 16 = minimum ciphertext length (auth tag alone, with empty plaintext)
if (blob.length < HEADER_LEN + 16) {
throw new Error("文件太小,不是合法的图集分享文件");
} }
for (let i = 0; i < MAGIC.length; i++) { for (let i = 0; i < MAGIC.length; i++) {
if (blob[i] !== MAGIC[i]) { if (blob[i] !== MAGIC[i]) {
throw new Error("文件格式不对,不是合法的图集分享文件"); throw new Error("文件格式不对,不是合法的分享文件");
} }
} }
const version = blob[MAGIC.length]; const version = blob[MAGIC.length];
if (version === 1) {
throw new Error("此文件由旧版本加密导出,当前版本不再支持加密格式");
}
if (version !== VERSION) { 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); const storedHash = blob.slice(MAGIC.length + 1, HEADER_LEN);
let plaintext: ArrayBuffer; const plaintext = blob.slice(HEADER_LEN);
try { const computedHash = new Uint8Array(
plaintext = await crypto.subtle.decrypt( await crypto.subtle.digest("SHA-256", plaintext),
{ name: "AES-GCM", iv: nonce },
key,
ciphertext,
); );
} catch {
// GCM auth tag failure → decryption refuses. Maps tamper + wrong-key both if (storedHash.length !== computedHash.length ||
// here, which is the right behavior: we can't distinguish, and neither !storedHash.every((b, i) => b === computedHash[i])) {
// should leak more than "this file isn't for this server". throw new Error("文件校验失败:内容可能被改动过");
throw new Error("文件校验失败:可能被改动过,或来自另一台部署");
} }
return new TextDecoder().decode(plaintext); return new TextDecoder().decode(plaintext);
} }
+1 -1
View File
@@ -40,7 +40,7 @@
// ── Secrets (set via Dashboard or `wrangler secret put`) ───────────── // ── Secrets (set via Dashboard or `wrangler secret put`) ─────────────
// Required (3): TEXT_API_KEY, IMAGE_API_KEY, VISION_API_KEY // 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) ──────────────────────────── // ── Runtime variables (set via Dashboard) ────────────────────────────
// Required (6): TEXT_BASE_URL, TEXT_MODEL, IMAGE_BASE_URL, IMAGE_MODEL, // Required (6): TEXT_BASE_URL, TEXT_MODEL, IMAGE_BASE_URL, IMAGE_MODEL,