Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,265 changes: 1,260 additions & 5 deletions backend/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
Expand Down Expand Up @@ -35,7 +36,8 @@
"@types/node": "^22.14.1",
"prettier": "^3.8.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.1.6"
},
"license": "AGPL-3.0-only"
}
3 changes: 3 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "dotenv/config";
import { assertSecretIsolation } from "./lib/startup";
import express from "express";
import cors from "cors";
import helmet from "helmet";
Expand All @@ -12,6 +13,8 @@ import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";

assertSecretIsolation();

const app = express();
const PORT = process.env.PORT ?? 3001;
const isProduction = process.env.NODE_ENV === "production";
Expand Down
124 changes: 124 additions & 0 deletions backend/src/lib/__tests__/secretIsolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { signDownload, verifyDownload } from "../downloadTokens";
import { saveUserApiKey } from "../userApiKeys";
import { assertSecretIsolation } from "../startup";
import type { SupabaseClient } from "@supabase/supabase-js";

const ENV_KEYS = [
"DOWNLOAD_SIGNING_SECRET",
"USER_API_KEYS_ENCRYPTION_SECRET",
"API_KEYS_ENCRYPTION_SECRET",
"SUPABASE_SECRET_KEY",
];

type EnvSnapshot = Record<string, string | undefined>;
let savedEnv: EnvSnapshot = {};

beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
for (const key of ENV_KEYS) delete process.env[key];
});

afterEach(() => {
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) delete process.env[key];
else process.env[key] = val;
}
});

const mockDb = {
from: (_table: string) => ({
upsert: (_data: unknown, _opts: unknown) =>
Promise.resolve({ error: null }),
delete: () => ({
eq: () => ({
eq: () => Promise.resolve({ error: null }),
}),
}),
}),
} as unknown as SupabaseClient;

describe("downloadTokens secret isolation", () => {
it("throws when DOWNLOAD_SIGNING_SECRET is absent even if SUPABASE_SECRET_KEY is set", () => {
process.env.SUPABASE_SECRET_KEY = "supabase-service-key";
expect(() => signDownload("/docs/brief.pdf", "brief.pdf")).toThrow();
});

it("signs and verifies a token when DOWNLOAD_SIGNING_SECRET is set", () => {
process.env.DOWNLOAD_SIGNING_SECRET = "dedicated-download-secret-32ch!!";
const token = signDownload("/docs/brief.pdf", "brief.pdf");
expect(verifyDownload(token)).toEqual({
path: "/docs/brief.pdf",
filename: "brief.pdf",
});
});

it("returns null for a tampered token", () => {
process.env.DOWNLOAD_SIGNING_SECRET = "dedicated-download-secret-32ch!!";
const token = signDownload("/docs/brief.pdf", "brief.pdf");
expect(verifyDownload(token + "x")).toBeNull();
});
});

describe("userApiKeys encryption secret isolation", () => {
it("throws when USER_API_KEYS_ENCRYPTION_SECRET is absent even if SUPABASE_SECRET_KEY is set", async () => {
process.env.SUPABASE_SECRET_KEY = "supabase-service-key";
await expect(
saveUserApiKey("user-id", "claude", "sk-ant-valid", mockDb),
).rejects.toThrow();
});

it("encrypts and saves a key when USER_API_KEYS_ENCRYPTION_SECRET is set", async () => {
process.env.USER_API_KEYS_ENCRYPTION_SECRET =
"dedicated-encryption-secret-32ch";
await expect(
saveUserApiKey("user-id", "claude", "sk-ant-valid", mockDb),
).resolves.not.toThrow();
});
});

describe("assertSecretIsolation", () => {
it("throws when DOWNLOAD_SIGNING_SECRET is missing", () => {
process.env.USER_API_KEYS_ENCRYPTION_SECRET = "enc-secret";
expect(() => assertSecretIsolation()).toThrow(/DOWNLOAD_SIGNING_SECRET/);
});

it("throws when USER_API_KEYS_ENCRYPTION_SECRET is missing", () => {
process.env.DOWNLOAD_SIGNING_SECRET = "dl-secret";
expect(() => assertSecretIsolation()).toThrow(
/USER_API_KEYS_ENCRYPTION_SECRET/,
);
});

it("throws when DOWNLOAD_SIGNING_SECRET equals SUPABASE_SECRET_KEY", () => {
process.env.SUPABASE_SECRET_KEY = "shared-secret";
process.env.DOWNLOAD_SIGNING_SECRET = "shared-secret";
process.env.USER_API_KEYS_ENCRYPTION_SECRET = "enc-secret";
expect(() => assertSecretIsolation()).toThrow(/DOWNLOAD_SIGNING_SECRET/);
});

it("throws when USER_API_KEYS_ENCRYPTION_SECRET equals SUPABASE_SECRET_KEY", () => {
process.env.SUPABASE_SECRET_KEY = "shared-secret";
process.env.DOWNLOAD_SIGNING_SECRET = "dl-secret";
process.env.USER_API_KEYS_ENCRYPTION_SECRET = "shared-secret";
expect(() => assertSecretIsolation()).toThrow(
/USER_API_KEYS_ENCRYPTION_SECRET/,
);
});

it("throws when DOWNLOAD_SIGNING_SECRET equals USER_API_KEYS_ENCRYPTION_SECRET", () => {
process.env.SUPABASE_SECRET_KEY = "supabase-secret";
process.env.DOWNLOAD_SIGNING_SECRET = "shared-app-secret";
process.env.USER_API_KEYS_ENCRYPTION_SECRET = "shared-app-secret";
expect(() => assertSecretIsolation()).toThrow(
/DOWNLOAD_SIGNING_SECRET.*USER_API_KEYS_ENCRYPTION_SECRET/,
);
});

it("passes when all secrets are set and distinct", () => {
process.env.SUPABASE_SECRET_KEY = "supabase-secret";
process.env.DOWNLOAD_SIGNING_SECRET = "download-secret";
process.env.USER_API_KEYS_ENCRYPTION_SECRET = "encryption-secret";
expect(() => assertSecretIsolation()).not.toThrow();
});
});
16 changes: 7 additions & 9 deletions backend/src/lib/downloadTokens.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import crypto from "crypto";

/**
* HMAC-signed, non-expiring download tokens.
* HMAC-signed download tokens with a configurable TTL (default 30 days).
*
* The token encodes the R2 storage path + filename; the backend route
* `/download/:token` validates the signature and streams the file. This
* gives persistent links safe to store in chat history without signed-URL
* expiry or R2 CORS headaches.
* The token encodes the R2 storage path, filename, and expiration time; the
* backend route `/download/:token` validates the signature and expiry before
* streaming the file. This gives persistent links safe to store in chat
* history without signed-URL expiry or R2 CORS headaches.
*/

function getSecret(): string {
const secret =
process.env.DOWNLOAD_SIGNING_SECRET ??
process.env.SUPABASE_SECRET_KEY;
const secret = process.env.DOWNLOAD_SIGNING_SECRET;
if (!secret) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET (or SUPABASE_SECRET_KEY as a fallback) must be set. " +
"DOWNLOAD_SIGNING_SECRET must be set. " +
"Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.",
);
}
Expand Down
31 changes: 31 additions & 0 deletions backend/src/lib/startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function assertSecretIsolation(): void {
const supabaseKey = process.env.SUPABASE_SECRET_KEY;
const downloadSecret = process.env.DOWNLOAD_SIGNING_SECRET;
const encryptionSecret = process.env.USER_API_KEYS_ENCRYPTION_SECRET;

const missing: string[] = [];
if (!downloadSecret) missing.push("DOWNLOAD_SIGNING_SECRET");
if (!encryptionSecret) missing.push("USER_API_KEYS_ENCRYPTION_SECRET");
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(", ")}. ` +
"Generate strong random values (e.g. `openssl rand -hex 32`) for each.",
);
}

if (supabaseKey && downloadSecret === supabaseKey) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET must not be the same as SUPABASE_SECRET_KEY.",
);
}
if (supabaseKey && encryptionSecret === supabaseKey) {
throw new Error(
"USER_API_KEYS_ENCRYPTION_SECRET must not be the same as SUPABASE_SECRET_KEY.",
);
}
if (downloadSecret === encryptionSecret) {
throw new Error(
"DOWNLOAD_SIGNING_SECRET and USER_API_KEYS_ENCRYPTION_SECRET must not be the same value.",
);
}
}
10 changes: 5 additions & 5 deletions backend/src/lib/userApiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ export function hasEnvApiKey(provider: ApiKeyProvider): boolean {
}

function encryptionKey(): Buffer {
const secret =
process.env.USER_API_KEYS_ENCRYPTION_SECRET ||
process.env.API_KEYS_ENCRYPTION_SECRET ||
process.env.SUPABASE_SECRET_KEY;
const secret = process.env.USER_API_KEYS_ENCRYPTION_SECRET;
if (!secret) {
throw new Error("API key encryption secret is not configured");
throw new Error(
"USER_API_KEYS_ENCRYPTION_SECRET must be set. " +
"Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.",
);
}
return crypto.createHash("sha256").update(secret).digest();
}
Expand Down
9 changes: 9 additions & 0 deletions backend/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "node",
globals: false,
exclude: ["dist/**", "node_modules/**"],
},
});