Skip to content

Commit dcaff09

Browse files
committed
refactor: simplify encryption key management and remove V1/V2 version distinction
- Make secretPepper configurable via ROOT_SECRET_PEPPER env var - Derive HKDF salt/info dynamically from pepper instead of hardcoding - Remove KeyVersion field from Project model (Go GORM + Python SQLAlchemy) - Merge DeriveMasterKeyWrappingKey into single DeriveUserKEK function - Rename ParseTokenV2 to ParseProjectToken, remove Version field - Consolidate ResetSecretKey + RotateSecretKey(V2) into single RotateSecretKey - Split key rotation into JWT admin route (non-encrypted only) and Bearer route - Remove extractMasterKey, OldAPIKey param, GetParsedToken from admin handler - Update Dashboard to route rotation based on encryption_enabled status - Remove legacy key_version checks from Dashboard settings page - Update E2E tests and unit tests to match simplified model
1 parent a67b262 commit dcaff09

20 files changed

Lines changed: 184 additions & 468 deletions

File tree

dashboard/app/project/[id]/api-keys/actions.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export async function getSecretKeyHistory(projectId: string) {
5959
* Rotate (generate new) secret key for a project
6060
* Returns the full key for one-time display
6161
*/
62-
export async function rotateSecretKey(projectId: string, oldApiKey?: string) {
62+
export async function rotateSecretKey(projectId: string, encryptionEnabled: boolean, apiKey?: string) {
6363
// Get current user (will redirect if not authenticated)
6464
const user = await getCurrentUser();
6565

@@ -86,8 +86,21 @@ export async function rotateSecretKey(projectId: string, oldApiKey?: string) {
8686

8787
try {
8888
// Call API to generate new secret key
89+
// Encrypted projects must use Bearer route to preserve master key
90+
// Non-encrypted projects can use either route
8991
const client = new AcontextClient();
90-
const fullSecretKey = await client.updateProjectSecretKey(projectId, oldApiKey);
92+
let fullSecretKey: string;
93+
if (encryptionEnabled) {
94+
if (!apiKey) {
95+
return { error: "Encrypted projects require a saved API key to rotate. Please save your API key first." };
96+
}
97+
fullSecretKey = await client.rotateProjectSecretKey(apiKey);
98+
} else if (apiKey) {
99+
// Prefer Bearer route when API key is available (preserves master key)
100+
fullSecretKey = await client.rotateProjectSecretKey(apiKey);
101+
} else {
102+
fullSecretKey = await client.rotateProjectSecretKeyAdmin(projectId);
103+
}
91104

92105
if (!fullSecretKey) {
93106
return { error: "Failed to generate secret key" };

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,11 @@ console.log(await client.ping());`,
164164

165165
const performKeyGeneration = () => {
166166
startTransition(async () => {
167-
// Pass the saved API key so the server can preserve the master key (V2 rotation)
168-
const result = await rotateSecretKey(project.id, savedApiKey ?? undefined);
167+
const result = await rotateSecretKey(
168+
project.id,
169+
project.encryption_enabled ?? false,
170+
savedApiKey ?? undefined,
171+
);
169172
if (result.error) {
170173
toast.error(result.error);
171174
} else if (result.secretKey) {

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

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -461,13 +461,6 @@ export function GeneralPageClient({
461461
);
462462
return;
463463
}
464-
if (checked && (project.key_version ?? 1) < 2) {
465-
toast.error(
466-
"Your API key uses a legacy format. Please rotate your API key first to upgrade it, then enable encryption.",
467-
{ duration: 7000 }
468-
);
469-
return;
470-
}
471464
if (checked) {
472465
setShowEncryptDialog(true);
473466
} else {
@@ -495,23 +488,6 @@ export function GeneralPageClient({
495488
</Alert>
496489
)}
497490

498-
{!encryptionEnabled && (project.key_version ?? 1) < 2 && (
499-
<Alert>
500-
<AlertTriangle className="h-4 w-4" />
501-
<AlertDescription>
502-
Your API key uses a legacy format that does not support encryption.
503-
Please{" "}
504-
<a
505-
href={`/project/${encodeId(project.id)}/api-keys`}
506-
className="font-medium underline underline-offset-4"
507-
>
508-
rotate your API key
509-
</a>{" "}
510-
to upgrade to the new format before enabling encryption.
511-
</AlertDescription>
512-
</Alert>
513-
)}
514-
515491
{encryptionEnabled && (
516492
<Alert>
517493
<Shield className="h-4 w-4" />

dashboard/lib/acontext/operations/admin.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,20 +133,33 @@ export function AdminOperations<T extends Constructor<BaseClient>>(Base: T) {
133133
}
134134

135135
/**
136-
* Update/rotate project secret key via acontext API.
137-
* If oldApiKey is provided, the master key is preserved (V2 rotation).
136+
* Rotate project secret key via admin JWT route (no master key preservation).
137+
* Only works for non-encrypted projects.
138138
* Returns the new secret key.
139139
*/
140-
async updateProjectSecretKey(projectId: string, oldApiKey?: string): Promise<string> {
141-
const body = oldApiKey ? { old_api_key: oldApiKey } : undefined;
140+
async rotateProjectSecretKeyAdmin(projectId: string): Promise<string> {
142141
const result = await this.request<{ secret_key?: string; secretKey?: string }>(
143142
`/admin/v1/project/${projectId}/secret_key`,
144143
{
145144
method: "PUT",
146-
...(body && {
147-
headers: { "Content-Type": "application/json" },
148-
body: JSON.stringify(body),
149-
}),
145+
}
146+
);
147+
return result.secret_key || result.secretKey || "";
148+
}
149+
150+
/**
151+
* Rotate project secret key via Bearer project auth route.
152+
* Preserves the master key (required for encrypted projects).
153+
* Returns the new secret key.
154+
*/
155+
async rotateProjectSecretKey(apiKey: string): Promise<string> {
156+
const result = await this.request<{ secret_key?: string; secretKey?: string }>(
157+
`/admin/v1/project/secret_key`,
158+
{
159+
method: "PUT",
160+
headers: {
161+
"Authorization": `Bearer ${apiKey}`,
162+
},
150163
}
151164
);
152165
return result.secret_key || result.secretKey || "";

dashboard/types/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ export interface Project {
2525
name: string
2626
organization_id: string
2727
encryption_enabled?: boolean
28-
key_version?: number
2928
created_at?: string
3029
}
3130

src/server/api/go/configs/config.admin.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ app:
77

88
root:
99
apiBearerToken: "${ROOT_API_BEARER_TOKEN}"
10-
secretPepper: "your-secret-pepper"
10+
secretPepper: "${ROOT_SECRET_PEPPER}"
1111
enableArgon2Verification: ${ENABLE_ARGON2_VERIFICATION}
1212

1313
log:

src/server/api/go/configs/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ app:
77

88
root:
99
apiBearerToken: "${ROOT_API_BEARER_TOKEN}"
10-
secretPepper: "your-secret-pepper"
10+
secretPepper: "${ROOT_SECRET_PEPPER}"
1111
enableArgon2Verification: ${ENABLE_ARGON2_VERIFICATION}
1212

1313
log:

src/server/api/go/internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func setDefaults(v *viper.Viper) {
123123
v.SetDefault("app.externalurl", "")
124124
v.SetDefault("root.apiBearerToken", "your-root-api-bearer-token")
125125
v.SetDefault("root.projectBearerTokenPrefix", "sk-ac-")
126+
v.SetDefault("root.secretPepper", "your-secret-pepper")
126127
v.SetDefault("root.enableArgon2Verification", true)
127128
v.SetDefault("database.dsn", "host=127.0.0.1 user=acontext password=helloworld dbname=acontext port=15432 sslmode=disable TimeZone=UTC")
128129
v.SetDefault("database.enableTLS", false)

src/server/api/go/internal/infra/crypto/envelope_test.go

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -283,35 +283,25 @@ func TestMetadataFromMap_NoEncryption(t *testing.T) {
283283
}
284284
}
285285

286-
func TestDeriveMasterKeyWrappingKey_Deterministic(t *testing.T) {
287-
k1, err := DeriveMasterKeyWrappingKey("auth-secret", "pepper")
286+
func TestDeriveUserKEK_Deterministic(t *testing.T) {
287+
k1, err := DeriveUserKEK("auth-secret", "pepper")
288288
if err != nil {
289289
t.Fatal(err)
290290
}
291-
k2, err := DeriveMasterKeyWrappingKey("auth-secret", "pepper")
291+
k2, err := DeriveUserKEK("auth-secret", "pepper")
292292
if err != nil {
293293
t.Fatal(err)
294294
}
295295
if !bytes.Equal(k1, k2) {
296-
t.Fatal("DeriveMasterKeyWrappingKey should be deterministic")
296+
t.Fatal("DeriveUserKEK should be deterministic")
297297
}
298298
if len(k1) != KeySize {
299299
t.Fatalf("expected key size %d, got %d", KeySize, len(k1))
300300
}
301301
}
302302

303-
func TestDeriveMasterKeyWrappingKey_DifferentFromUserKEK(t *testing.T) {
304-
// Wrapping key should differ from user KEK even with same inputs
305-
// (different domain separation)
306-
wk, _ := DeriveMasterKeyWrappingKey("secret", "pepper")
307-
uk, _ := DeriveUserKEK("secret", "pepper")
308-
if bytes.Equal(wk, uk) {
309-
t.Fatal("wrapping key and user KEK should differ (different HKDF domain)")
310-
}
311-
}
312-
313303
func TestWrapUnwrapMasterKey(t *testing.T) {
314-
wk, _ := DeriveMasterKeyWrappingKey("auth-secret", "pepper")
304+
wk, _ := DeriveUserKEK("auth-secret", "pepper")
315305
mk, err := GenerateMasterKey()
316306
if err != nil {
317307
t.Fatal(err)
@@ -335,8 +325,8 @@ func TestWrapUnwrapMasterKey(t *testing.T) {
335325
}
336326

337327
func TestUnwrapMasterKey_WrongWrappingKey(t *testing.T) {
338-
wk1, _ := DeriveMasterKeyWrappingKey("auth1", "pepper")
339-
wk2, _ := DeriveMasterKeyWrappingKey("auth2", "pepper")
328+
wk1, _ := DeriveUserKEK("auth1", "pepper")
329+
wk2, _ := DeriveUserKEK("auth2", "pepper")
340330
mk, _ := GenerateMasterKey()
341331

342332
encB64, _ := WrapMasterKey(wk1, mk)
@@ -376,8 +366,8 @@ func TestMasterKeyRewrapPreservesDecryption(t *testing.T) {
376366
ciphertext, meta, _ := EncryptData(mk, plaintext)
377367

378368
// Re-wrap master_key with new auth_secret (simulating rotation)
379-
oldWK, _ := DeriveMasterKeyWrappingKey("old-auth", "pepper")
380-
newWK, _ := DeriveMasterKeyWrappingKey("new-auth", "pepper")
369+
oldWK, _ := DeriveUserKEK("old-auth", "pepper")
370+
newWK, _ := DeriveUserKEK("new-auth", "pepper")
381371

382372
encB64, _ := WrapMasterKey(oldWK, mk)
383373
unwrapped, _ := UnwrapMasterKey(oldWK, encB64)

src/server/api/go/internal/infra/crypto/service.go

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,13 @@ import (
66
"fmt"
77
)
88

9-
var (
10-
userKEKSalt = []byte("acontext-user-kek")
11-
userKEKInfo = []byte("acontext envelope encryption user KEK")
12-
13-
masterKeyWrapSalt = []byte("acontext-master-key-wrap")
14-
masterKeyWrapInfo = []byte("acontext master key wrapping")
15-
)
16-
17-
// DeriveUserKEK derives a user KEK from the raw API key and pepper.
18-
func DeriveUserKEK(apiKeyRaw, pepper string) ([]byte, error) {
19-
secret := []byte(apiKeyRaw + pepper)
20-
return DeriveKEK(secret, userKEKSalt, userKEKInfo)
21-
}
22-
23-
// DeriveMasterKeyWrappingKey derives a wrapping key from the auth secret and pepper.
24-
// This key is used to encrypt/decrypt the master key embedded in V2 API tokens.
25-
func DeriveMasterKeyWrappingKey(authSecret, pepper string) ([]byte, error) {
9+
// DeriveUserKEK derives a wrapping key from the auth secret and pepper.
10+
// This key is used to encrypt/decrypt the master key embedded in API tokens.
11+
func DeriveUserKEK(authSecret, pepper string) ([]byte, error) {
2612
secret := []byte(authSecret + pepper)
27-
return DeriveKEK(secret, masterKeyWrapSalt, masterKeyWrapInfo)
13+
salt := []byte(pepper + "-master-key-wrap")
14+
info := []byte(pepper + " master key wrapping")
15+
return DeriveKEK(secret, salt, info)
2816
}
2917

3018
// WrapMasterKey encrypts a 32-byte master key with a wrapping key derived from auth_secret.

0 commit comments

Comments
 (0)