Skip to content

Commit b0beff1

Browse files
committed
Encrypt module secrets client-side
1 parent c53a6a6 commit b0beff1

5 files changed

Lines changed: 359 additions & 23 deletions

File tree

apps/web/src/app/account/account-content.tsx

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
44
import Link from "next/link";
55
import { useAuth } from "@/lib/auth-context";
66
import { authHeaders } from "@/lib/auth-client";
7+
import { decryptClientSecret, encryptClientSecret, isE2ESecret } from "@/lib/client-secret-crypto";
78
import { parseWalletPaste, formatWalletCopyText } from "@/lib/wallet-import";
89

910
interface ReferralWallet {
@@ -37,6 +38,10 @@ export default function AccountContent() {
3738
const [showAllWallets, setShowAllWallets] = useState(false);
3839
const [aiGatewayKeyDraft, setAiGatewayKeyDraft] = useState("");
3940
const [aiGatewayKeySet, setAiGatewayKeySet] = useState(false);
41+
const [aiGatewayKeyE2E, setAiGatewayKeyE2E] = useState(false);
42+
const [revealedAiGatewayKey, setRevealedAiGatewayKey] = useState("");
43+
const [revealingAiGatewayKey, setRevealingAiGatewayKey] = useState(false);
44+
const [copiedAiGatewayKey, setCopiedAiGatewayKey] = useState(false);
4045
const [settingsSaving, setSettingsSaving] = useState(false);
4146
const [settingsStatus, setSettingsStatus] = useState<string | null>(null);
4247
const [settingsError, setSettingsError] = useState<string | null>(null);
@@ -72,6 +77,7 @@ export default function AccountContent() {
7277
if (!res.ok) return;
7378
const data = await res.json();
7479
setAiGatewayKeySet(!!data.secrets?.AI_GATEWAY_API_KEY?.isSet);
80+
setAiGatewayKeyE2E(!!data.secrets?.AI_GATEWAY_API_KEY?.e2eEncrypted);
7581
} catch {
7682
// ignore
7783
}
@@ -199,15 +205,19 @@ export default function AccountContent() {
199205
setSettingsStatus(null);
200206
setSettingsError(null);
201207
try {
208+
if (!profile?.id) throw new Error("Account profile is still loading");
209+
const secretValue = value === null ? null : await encryptClientSecret(profile.id, value.trim());
202210
const res = await fetch("/api/settings", {
203211
method: "PUT",
204212
headers: authHeaders({ "Content-Type": "application/json" }),
205-
body: JSON.stringify({ secrets: { AI_GATEWAY_API_KEY: value } }),
213+
body: JSON.stringify({ secrets: { AI_GATEWAY_API_KEY: secretValue } }),
206214
});
207215
const data = await res.json();
208216
if (!res.ok) throw new Error(data.error || "Failed to save settings");
209217
setAiGatewayKeySet(!!data.secrets?.AI_GATEWAY_API_KEY?.isSet);
218+
setAiGatewayKeyE2E(!!data.secrets?.AI_GATEWAY_API_KEY?.e2eEncrypted);
210219
setAiGatewayKeyDraft("");
220+
setRevealedAiGatewayKey("");
211221
setSettingsStatus(value === null ? "Cleared" : "Saved");
212222
} catch (error) {
213223
setSettingsError((error as Error).message);
@@ -216,6 +226,53 @@ export default function AccountContent() {
216226
}
217227
};
218228

229+
const revealAiGatewayKey = async () => {
230+
setSettingsStatus(null);
231+
setSettingsError(null);
232+
setRevealingAiGatewayKey(true);
233+
try {
234+
if (!profile?.id) throw new Error("Account profile is still loading");
235+
const res = await fetch("/api/settings?includeSecretValues=1", {
236+
headers: authHeaders(),
237+
cache: "no-store",
238+
});
239+
const data = await res.json();
240+
if (!res.ok) throw new Error(data.error || "Failed to load settings");
241+
242+
const stored = data.secretValues?.AI_GATEWAY_API_KEY;
243+
if (typeof stored !== "string" || !stored) throw new Error("No saved Vercel key found");
244+
245+
const value = await decryptClientSecret(profile.id, stored);
246+
setRevealedAiGatewayKey(value);
247+
248+
if (!isE2ESecret(stored)) {
249+
const encrypted = await encryptClientSecret(profile.id, value);
250+
const upgradeRes = await fetch("/api/settings", {
251+
method: "PUT",
252+
headers: authHeaders({ "Content-Type": "application/json" }),
253+
body: JSON.stringify({ secrets: { AI_GATEWAY_API_KEY: encrypted } }),
254+
});
255+
if (upgradeRes.ok) {
256+
const upgraded = await upgradeRes.json();
257+
setAiGatewayKeySet(!!upgraded.secrets?.AI_GATEWAY_API_KEY?.isSet);
258+
setAiGatewayKeyE2E(!!upgraded.secrets?.AI_GATEWAY_API_KEY?.e2eEncrypted);
259+
setSettingsStatus("Revealed and upgraded to E2E encryption");
260+
}
261+
}
262+
} catch (error) {
263+
setSettingsError((error as Error).message);
264+
} finally {
265+
setRevealingAiGatewayKey(false);
266+
}
267+
};
268+
269+
const copyAiGatewayKey = async () => {
270+
if (!revealedAiGatewayKey) return;
271+
await navigator.clipboard?.writeText(revealedAiGatewayKey);
272+
setCopiedAiGatewayKey(true);
273+
setTimeout(() => setCopiedAiGatewayKey(false), 2000);
274+
};
275+
219276
return (
220277
<div className="min-h-screen bg-tc-darker">
221278
{/* Nav */}
@@ -342,18 +399,49 @@ export default function AccountContent() {
342399
{settingsSaving ? "Saving..." : "Save"}
343400
</button>
344401
{aiGatewayKeySet && (
402+
<>
403+
<button
404+
onClick={revealAiGatewayKey}
405+
disabled={settingsSaving || revealingAiGatewayKey}
406+
className="border border-tc-border text-tc-text-dim px-4 py-2 rounded-lg text-sm hover:border-tc-green/40 hover:text-tc-green disabled:opacity-50"
407+
>
408+
{revealingAiGatewayKey ? "Decrypting..." : "Reveal"}
409+
</button>
410+
<button
411+
onClick={() => saveAiGatewayKey(null)}
412+
disabled={settingsSaving}
413+
className="border border-tc-border text-tc-text-dim px-4 py-2 rounded-lg text-sm hover:border-red-400/40 hover:text-red-400 disabled:opacity-50"
414+
>
415+
Clear
416+
</button>
417+
</>
418+
)}
419+
</div>
420+
{revealedAiGatewayKey && (
421+
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-tc-border bg-black/40 p-2 sm:flex-row">
422+
<input
423+
readOnly
424+
type="text"
425+
value={revealedAiGatewayKey}
426+
className="flex-1 rounded border border-tc-border bg-tc-darker px-2 py-1.5 font-mono text-xs text-tc-text focus:outline-none"
427+
/>
345428
<button
346-
onClick={() => saveAiGatewayKey(null)}
347-
disabled={settingsSaving}
348-
className="border border-tc-border text-tc-text-dim px-4 py-2 rounded-lg text-sm hover:border-red-400/40 hover:text-red-400 disabled:opacity-50"
429+
type="button"
430+
onClick={copyAiGatewayKey}
431+
className="rounded border border-tc-border px-3 py-1.5 text-xs text-tc-text-dim hover:border-tc-green/40 hover:text-tc-green"
349432
>
350-
Clear
433+
{copiedAiGatewayKey ? "Copied" : "Copy"}
351434
</button>
352-
)}
353-
</div>
435+
</div>
436+
)}
354437
<p className="text-xs text-tc-text-dim mt-2">
355438
Used by AI-powered modules such as DeepSec. The key is stored in your account settings, not in the public plugin store.
356439
</p>
440+
{aiGatewayKeySet && (
441+
<p className="text-xs text-tc-text-dim mt-1">
442+
{aiGatewayKeyE2E ? "E2E encrypted in this browser." : "Legacy secret; reveal once to upgrade it to E2E encryption."}
443+
</p>
444+
)}
357445
{settingsStatus && <p className="text-xs text-tc-green mt-2">{settingsStatus}</p>}
358446
{settingsError && <p className="text-xs text-red-400 mt-2">{settingsError}</p>}
359447
</div>

apps/web/src/app/api/settings/__tests__/route.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ vi.mock("@/lib/settings-crypto", () => ({
3232

3333
import { GET, PUT } from "@/app/api/settings/route";
3434

35-
function request(body?: unknown) {
36-
return new Request("http://localhost/api/settings", {
35+
function request(body?: unknown, url = "http://localhost/api/settings") {
36+
return new Request(url, {
3737
method: body === undefined ? "GET" : "PUT",
3838
headers: { "Content-Type": "application/json", Authorization: "Bearer token" },
3939
body: body === undefined ? undefined : JSON.stringify(body),
@@ -67,11 +67,20 @@ describe("/api/settings", () => {
6767
expect(res.status).toBe(200);
6868
expect(body.plain).toEqual({ DEEPSEC_AGENT: "codex" });
6969
expect(body.secrets).toEqual({
70-
AI_GATEWAY_API_KEY: { isSet: true, length: "vck_test_secret".length },
70+
AI_GATEWAY_API_KEY: { isSet: true, length: "vck_test_secret".length, e2eEncrypted: false },
7171
});
72+
expect(body.secretValues).toBeUndefined();
7273
expect(JSON.stringify(body)).not.toContain("vck_test_secret");
7374
});
7475

76+
it("returns secret values only for an explicit reveal request", async () => {
77+
const res = await GET(request(undefined, "http://localhost/api/settings?includeSecretValues=1"));
78+
const body = await res.json();
79+
80+
expect(res.status).toBe(200);
81+
expect(body.secretValues).toEqual({ AI_GATEWAY_API_KEY: "vck_test_secret" });
82+
});
83+
7584
it("merges plain settings and encrypts updated secrets", async () => {
7685
const res = await PUT(request({
7786
plain: { DEEPSEC_AGENT: "claude" },
@@ -91,7 +100,7 @@ describe("/api/settings", () => {
91100
}),
92101
{ onConflict: "user_id" },
93102
);
94-
expect(body.secrets.AI_GATEWAY_API_KEY).toEqual({ isSet: true, length: 7 });
103+
expect(body.secrets.AI_GATEWAY_API_KEY).toEqual({ isSet: true, length: 7, e2eEncrypted: false });
95104
});
96105

97106
it("clears a secret when the value is null", async () => {

apps/web/src/app/api/settings/route.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type SettingsRow = {
1616
};
1717

1818
const KEY_RE = /^[A-Z][A-Z0-9_]{1,100}$/;
19+
const E2E_SECRET_PREFIX = "tc_e2e_secret_v1:";
1920

2021
function rowToCipher(row: SettingsRow | null): CipherBlob | null {
2122
if (!row?.payload_secret_ciphertext || !row.payload_secret_iv || !row.payload_secret_tag) return null;
@@ -28,7 +29,14 @@ function rowToCipher(row: SettingsRow | null): CipherBlob | null {
2829

2930
function secretStatus(secrets: Record<string, string>) {
3031
return Object.fromEntries(
31-
Object.entries(secrets).map(([key, value]) => [key, { isSet: true, length: value.length }]),
32+
Object.entries(secrets).map(([key, value]) => [
33+
key,
34+
{
35+
isSet: true,
36+
length: value.startsWith(E2E_SECRET_PREFIX) ? undefined : value.length,
37+
e2eEncrypted: value.startsWith(E2E_SECRET_PREFIX),
38+
},
39+
]),
3240
);
3341
}
3442

@@ -84,12 +92,23 @@ export async function GET(request: NextRequest) {
8492
secrets = {};
8593
}
8694

87-
return NextResponse.json({
95+
const includeSecretValues = new URL(request.url).searchParams.get("includeSecretValues") === "1";
96+
const response: {
97+
plain: Record<string, unknown>;
98+
secrets: ReturnType<typeof secretStatus>;
99+
cryptoConfigured: boolean;
100+
lastUpdatedAt: string | null;
101+
secretValues?: Record<string, string>;
102+
} = {
88103
plain: row?.payload_plain || {},
89104
secrets: secretStatus(secrets),
90105
cryptoConfigured: settingsCryptoConfigured(),
91106
lastUpdatedAt: row?.updated_at || null,
92-
});
107+
};
108+
109+
if (includeSecretValues) response.secretValues = secrets;
110+
111+
return NextResponse.json(response);
93112
}
94113

95114
export async function PUT(request: NextRequest) {

0 commit comments

Comments
 (0)