Skip to content

Commit e20c0ce

Browse files
authored
fix(dashboard,api): improve encryption error handling for legacy API keys (#513)
Legacy API keys lack embedded master keys and cannot derive KEK for encryption. The API returned a generic "parameter error" with no guidance, and the Dashboard had no client-side validation. - API: return descriptive error messages instead of generic "parameter error" - Dashboard: detect legacy vs compact keys using configurable prefix - Dashboard: show warning banner and disable toggle for legacy keys - Dashboard: validate API key prefix on save - Dashboard: add eye toggle and autoComplete=off on API key input
1 parent 9396847 commit e20c0ce

7 files changed

Lines changed: 108 additions & 23 deletions

File tree

dashboard/app/project/[id]/api-keys/api-keys-page-client.tsx

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import {
5959
import { rotateSecretKey } from "./actions";
6060
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
6161
import { CodeEditor } from "@/components/code-editor";
62-
import { useApiKeyStorage } from "@/lib/hooks/use-api-key-storage";
62+
import { useApiKeyStorage, hasValidApiKeyPrefix } from "@/lib/hooks/use-api-key-storage";
6363

6464
const SERVICE_URL = "api.acontext.app/api/v1";
6565

@@ -70,6 +70,7 @@ interface ApiKeysPageClientProps {
7070
projects: Project[];
7171
keyRotations: SecretKeyRotation[];
7272
role: "owner" | "member";
73+
apiKeyPrefix: string;
7374
}
7475

7576
export function ApiKeysPageClient({
@@ -79,6 +80,7 @@ export function ApiKeysPageClient({
7980
projects,
8081
keyRotations,
8182
role,
83+
apiKeyPrefix,
8284
}: ApiKeysPageClientProps) {
8385
const { initialize, setHasSidebar } = useTopNavStore();
8486
const router = useRouter();
@@ -95,9 +97,10 @@ export function ApiKeysPageClient({
9597
);
9698

9799
// Saved API key in localStorage
98-
const { apiKey: savedApiKey, hasApiKey: hasSavedApiKey, saveApiKey, removeApiKey } = useApiKeyStorage(project.id);
100+
const { apiKey: savedApiKey, hasApiKey: hasSavedApiKey, saveApiKey, removeApiKey } = useApiKeyStorage(project.id, apiKeyPrefix);
99101
const [apiKeyInput, setApiKeyInput] = useState("");
100102
const [showSavedKey, setShowSavedKey] = useState(false);
103+
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
101104

102105
const isOwner = role === "owner";
103106
const hasExistingKey = keyRotations.length > 0;
@@ -519,21 +522,43 @@ IMPORTANT: Store this key securely. It will not be shown again.
519522
<div className="space-y-2">
520523
<Label htmlFor="api-key-input">API Key</Label>
521524
<div className="flex items-center gap-2">
522-
<Input
523-
id="api-key-input"
524-
type="password"
525-
value={apiKeyInput}
526-
onChange={(e) => setApiKeyInput(e.target.value)}
527-
placeholder="Paste your API key here"
528-
className="font-mono text-sm"
529-
/>
525+
<div className="relative flex-1">
526+
<Input
527+
id="api-key-input"
528+
type={showApiKeyInput ? "text" : "password"}
529+
value={apiKeyInput}
530+
onChange={(e) => setApiKeyInput(e.target.value)}
531+
placeholder="Paste your API key here"
532+
className="font-mono text-sm pr-10"
533+
autoComplete="off"
534+
/>
535+
<Button
536+
variant="ghost"
537+
size="icon"
538+
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
539+
onClick={() => setShowApiKeyInput(!showApiKeyInput)}
540+
title={showApiKeyInput ? "Hide API key" : "Show API key"}
541+
type="button"
542+
>
543+
{showApiKeyInput ? (
544+
<EyeOff className="h-4 w-4" />
545+
) : (
546+
<Eye className="h-4 w-4" />
547+
)}
548+
</Button>
549+
</div>
530550
<Button
531551
onClick={() => {
532-
if (apiKeyInput.trim()) {
533-
saveApiKey(apiKeyInput.trim());
534-
setApiKeyInput("");
535-
toast.success("API key saved to browser");
552+
const trimmed = apiKeyInput.trim();
553+
if (!trimmed) return;
554+
if (!hasValidApiKeyPrefix(trimmed, apiKeyPrefix)) {
555+
toast.error(`Invalid API key format. API key must start with "${apiKeyPrefix}".`);
556+
return;
536557
}
558+
saveApiKey(trimmed);
559+
setApiKeyInput("");
560+
setShowApiKeyInput(false);
561+
toast.success("API key saved to browser");
537562
}}
538563
disabled={!apiKeyInput.trim()}
539564
>

dashboard/app/project/[id]/api-keys/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getSecretKeyRotations,
88
} from "@/lib/supabase";
99
import { decodeId } from "@/lib/id-codec";
10+
import { getProjectApiKeyPrefix } from "@/lib/acontext/server";
1011

1112
interface PageProps {
1213
params: Promise<{
@@ -70,6 +71,7 @@ export default async function ApiKeysPage({ params }: PageProps) {
7071
projects={projects}
7172
keyRotations={keyRotations}
7273
role={currentOrganization.role ?? "member"}
74+
apiKeyPrefix={getProjectApiKeyPrefix()}
7375
/>
7476
);
7577
}

dashboard/app/project/[id]/settings/encryption/encryption-page-client.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useEffect, useState, useTransition } from "react";
44
import { useRouter } from "next/navigation";
55
import { encodeId } from "@/lib/id-codec";
6-
import { AlertTriangle, Lock, Loader2, Shield } from "lucide-react";
6+
import { AlertTriangle, KeyRound, Lock, Loader2, Shield } from "lucide-react";
77
import { toast } from "sonner";
88
import { useTopNavStore } from "@/stores/top-nav";
99
import { Organization, Project } from "@/types";
@@ -36,6 +36,7 @@ interface EncryptionPageClientProps {
3636
allOrganizations: Organization[];
3737
projects: Project[];
3838
role: "owner" | "member";
39+
apiKeyPrefix: string;
3940
}
4041

4142
export function EncryptionPageClient({
@@ -44,6 +45,7 @@ export function EncryptionPageClient({
4445
allOrganizations,
4546
projects,
4647
role,
48+
apiKeyPrefix,
4749
}: EncryptionPageClientProps) {
4850
const { initialize, setHasSidebar } = useTopNavStore();
4951
const router = useRouter();
@@ -54,7 +56,7 @@ export function EncryptionPageClient({
5456
const [showEncryptDialog, setShowEncryptDialog] = useState(false);
5557
const [showDecryptDialog, setShowDecryptDialog] = useState(false);
5658
const [isEncryptionPending, startEncryptionTransition] = useTransition();
57-
const { hasApiKey, apiKey } = useApiKeyStorage(project.id);
59+
const { hasApiKey, apiKey, isCompactKey } = useApiKeyStorage(project.id, apiKeyPrefix);
5860

5961
useEffect(() => {
6062
initialize({
@@ -119,13 +121,20 @@ export function EncryptionPageClient({
119121
);
120122
return;
121123
}
124+
if (!isCompactKey) {
125+
toast.error(
126+
"Your API key is in legacy format and does not support encryption. Please rotate your API key on the API Keys page to get a new key that supports encryption.",
127+
{ duration: 8000 }
128+
);
129+
return;
130+
}
122131
if (checked) {
123132
setShowEncryptDialog(true);
124133
} else {
125134
setShowDecryptDialog(true);
126135
}
127136
}}
128-
disabled={isEncryptionPending || !isOwner}
137+
disabled={isEncryptionPending || !isOwner || (hasApiKey && !isCompactKey)}
129138
/>
130139
</div>
131140
</div>
@@ -146,6 +155,23 @@ export function EncryptionPageClient({
146155
</Alert>
147156
)}
148157

158+
{hasApiKey && !isCompactKey && (
159+
<Alert variant="destructive">
160+
<KeyRound className="h-4 w-4" />
161+
<AlertDescription>
162+
Your saved API key is in legacy format and does not support encryption.
163+
Please go to the{" "}
164+
<a
165+
href={`/project/${encodeId(project.id)}/api-keys`}
166+
className="font-medium underline underline-offset-4"
167+
>
168+
API Keys page
169+
</a>{" "}
170+
and rotate your API key to get a new key that supports encryption.
171+
</AlertDescription>
172+
</Alert>
173+
)}
174+
149175
{encryptionEnabled && (
150176
<Alert>
151177
<Shield className="h-4 w-4" />

dashboard/app/project/[id]/settings/encryption/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getOrganizationDataWithPlan,
77
} from "@/lib/supabase";
88
import { decodeId } from "@/lib/id-codec";
9+
import { getProjectApiKeyPrefix } from "@/lib/acontext/server";
910

1011
interface PageProps {
1112
params: Promise<{
@@ -60,6 +61,7 @@ export default async function EncryptionPage({ params }: PageProps) {
6061
allOrganizations={allOrganizations}
6162
projects={projects}
6263
role={currentOrganization.role ?? "member"}
64+
apiKeyPrefix={getProjectApiKeyPrefix()}
6365
/>
6466
);
6567
}

dashboard/lib/acontext/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export * from "./operations";
2323
const ACONTEXT_API_BASE_URL =
2424
process.env.ACONTEXT_API_BASE_URL ?? "https://admin.acontext.app";
2525

26+
/**
27+
* Get the project API key prefix from environment.
28+
* Use this to pass the prefix to client components that need to validate API key format.
29+
*/
30+
export function getProjectApiKeyPrefix(): string {
31+
return process.env.ACONTEXT_PROJECT_BEARER_TOKEN_PREFIX || "sk-ac-";
32+
}
33+
2634
/**
2735
* Base Acontext API Client class
2836
* Contains core infrastructure: authentication, request handling, and utilities

dashboard/lib/hooks/use-api-key-storage.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,26 @@
22
import { useState, useCallback } from "react";
33

44
const STORAGE_KEY_PREFIX = "acontext_api_key_";
5+
// Compact format body: base64url(0x01 | auth_16B | aes_kw_40B) = 76 chars
6+
const COMPACT_BODY_LENGTH = 76;
57

6-
export function useApiKeyStorage(projectId: string) {
8+
/**
9+
* Check if an API key has a valid prefix.
10+
*/
11+
export function hasValidApiKeyPrefix(key: string, prefix: string): boolean {
12+
return key.startsWith(prefix);
13+
}
14+
15+
/**
16+
* Check if an API key is in compact format (supports encryption).
17+
* Compact keys: prefix + 76-char base64url body.
18+
* Legacy keys have a different body length and do not support encryption.
19+
*/
20+
export function isCompactApiKey(key: string, prefix: string): boolean {
21+
return hasValidApiKeyPrefix(key, prefix) && key.length === prefix.length + COMPACT_BODY_LENGTH;
22+
}
23+
24+
export function useApiKeyStorage(projectId: string, apiKeyPrefix: string) {
725
const storageKey = `${STORAGE_KEY_PREFIX}${projectId}`;
826

927
const [apiKey, setApiKeyState] = useState<string | null>(() => {
@@ -24,9 +42,13 @@ export function useApiKeyStorage(projectId: string) {
2442
setApiKeyState(null);
2543
}, [storageKey]);
2644

45+
const hasApiKey = apiKey !== null && apiKey !== "";
46+
2747
return {
2848
apiKey,
29-
hasApiKey: apiKey !== null && apiKey !== "",
49+
hasApiKey,
50+
hasValidPrefix: hasApiKey && hasValidApiKeyPrefix(apiKey!, apiKeyPrefix),
51+
isCompactKey: hasApiKey && isCompactApiKey(apiKey!, apiKeyPrefix),
3052
saveApiKey,
3153
removeApiKey,
3254
};

src/server/api/go/internal/modules/handler/encryption.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ import (
2020
func encryptProject(c *gin.Context, db *gorm.DB, rdb *redis.Client, s3 *blob.S3Deps, assetRefRepo repo.AssetReferenceRepo) {
2121
project, ok := c.MustGet("project").(*model.Project)
2222
if !ok {
23-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
23+
c.JSON(http.StatusBadRequest, serializer.ParamErr("project not found", fmt.Errorf("project not found")))
2424
return
2525
}
2626

2727
userKEK := middleware.GetUserKEK(c)
2828
if userKEK == nil {
29-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
29+
c.JSON(http.StatusBadRequest, serializer.ParamErr("compact API key required to derive encryption key; rotate your API key to enable encryption", nil))
3030
return
3131
}
3232

@@ -73,13 +73,13 @@ func encryptProject(c *gin.Context, db *gorm.DB, rdb *redis.Client, s3 *blob.S3D
7373
func decryptProject(c *gin.Context, db *gorm.DB, rdb *redis.Client, s3 *blob.S3Deps, assetRefRepo repo.AssetReferenceRepo) {
7474
project, ok := c.MustGet("project").(*model.Project)
7575
if !ok {
76-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("project not found")))
76+
c.JSON(http.StatusBadRequest, serializer.ParamErr("project not found", fmt.Errorf("project not found")))
7777
return
7878
}
7979

8080
userKEK := middleware.GetUserKEK(c)
8181
if userKEK == nil {
82-
c.JSON(http.StatusBadRequest, serializer.ParamErr("", fmt.Errorf("API key required to derive encryption key")))
82+
c.JSON(http.StatusBadRequest, serializer.ParamErr("compact API key required to derive encryption key; rotate your API key to disable encryption", nil))
8383
return
8484
}
8585

0 commit comments

Comments
 (0)