Skip to content

Commit b4c0101

Browse files
committed
fix: align Go/Python HKDF constants, deduplicate encryption actions, extract metadata keys
- Fix critical HKDF salt/info mismatch between Go and Python crypto modules that would produce different KEKs for the same input - Replace raw fetch with AcontextClient in encrypt/decrypt server actions and deduplicate into shared toggleProjectEncryption helper - Remove redundant encryptionEnabled parameter from rotateSecretKey (now derived from project record fetched server-side) - Extract MetaKeyAlgo/MetaKeyDEKUser constants and ClearEncryptionMetadata helper from crypto package; replace hard-coded strings in s3.go - Remove unused EncodeBase64/DecodeBase64 wrappers and WHAT-comments
1 parent dcaff09 commit b4c0101

7 files changed

Lines changed: 46 additions & 99 deletions

File tree

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

Lines changed: 2 additions & 5 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, encryptionEnabled: boolean, apiKey?: string) {
62+
export async function rotateSecretKey(projectId: string, apiKey?: string) {
6363
// Get current user (will redirect if not authenticated)
6464
const user = await getCurrentUser();
6565

@@ -85,18 +85,15 @@ export async function rotateSecretKey(projectId: string, encryptionEnabled: bool
8585
}
8686

8787
try {
88-
// Call API to generate new secret key
8988
// Encrypted projects must use Bearer route to preserve master key
90-
// Non-encrypted projects can use either route
9189
const client = new AcontextClient();
9290
let fullSecretKey: string;
93-
if (encryptionEnabled) {
91+
if (project.encryption_enabled) {
9492
if (!apiKey) {
9593
return { error: "Encrypted projects require a saved API key to rotate. Please save your API key first." };
9694
}
9795
fullSecretKey = await client.rotateProjectSecretKey(apiKey);
9896
} else if (apiKey) {
99-
// Prefer Bearer route when API key is available (preserves master key)
10097
fullSecretKey = await client.rotateProjectSecretKey(apiKey);
10198
} else {
10299
fullSecretKey = await client.rotateProjectSecretKeyAdmin(projectId);

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ console.log(await client.ping());`,
166166
startTransition(async () => {
167167
const result = await rotateSecretKey(
168168
project.id,
169-
project.encryption_enabled ?? false,
170169
savedApiKey ?? undefined,
171170
);
172171
if (result.error) {

dashboard/app/project/[id]/settings/general/actions.ts

Lines changed: 18 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,10 @@ export async function getProjectConfigs(
146146
}
147147
}
148148

149-
export async function encryptProjectAction(
149+
async function toggleProjectEncryption(
150150
projectId: string,
151-
apiKey: string
151+
apiKey: string,
152+
action: "encrypt" | "decrypt"
152153
): Promise<{ success?: boolean; error?: string }> {
153154
try {
154155
await getCurrentUser();
@@ -166,17 +167,14 @@ export async function encryptProjectAction(
166167
return { error: "Project not found or access denied" };
167168
}
168169
if (membership.role !== "owner") {
169-
return { error: "Only organization owners can enable encryption" };
170+
return { error: `Only organization owners can ${action === "encrypt" ? "enable" : "disable"} encryption` };
170171
}
171172

172-
const baseUrl = process.env.ACONTEXT_API_BASE_URL ?? "https://admin.acontext.app";
173-
const resp = await fetch(`${baseUrl}/admin/v1/project/encrypt`, {
174-
method: "POST",
175-
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
176-
});
177-
if (!resp.ok) {
178-
const body = await resp.json().catch(() => ({}));
179-
throw new Error(body.msg || `HTTP ${resp.status}`);
173+
const client = new AcontextClient();
174+
if (action === "encrypt") {
175+
await client.encryptProject(projectId, apiKey);
176+
} else {
177+
await client.decryptProject(projectId, apiKey);
180178
}
181179

182180
const encodedProjectId = encodeId(projectId);
@@ -185,53 +183,23 @@ export async function encryptProjectAction(
185183
return { success: true };
186184
} catch (error) {
187185
return {
188-
error: `Failed to encrypt project: ${error instanceof Error ? error.message : "Unknown error"}`,
186+
error: `Failed to ${action} project: ${error instanceof Error ? error.message : "Unknown error"}`,
189187
};
190188
}
191189
}
192190

193-
export async function decryptProjectAction(
191+
export async function encryptProjectAction(
194192
projectId: string,
195193
apiKey: string
196194
): Promise<{ success?: boolean; error?: string }> {
197-
try {
198-
await getCurrentUser();
199-
200-
const project = await getProject(projectId);
201-
if (!project) {
202-
return { error: "Project not found" };
203-
}
204-
205-
const membership = await getOrganizationMembershipForCurrentUser(
206-
project.organization_id,
207-
"role"
208-
);
209-
if (!membership) {
210-
return { error: "Project not found or access denied" };
211-
}
212-
if (membership.role !== "owner") {
213-
return { error: "Only organization owners can disable encryption" };
214-
}
215-
216-
const baseUrl = process.env.ACONTEXT_API_BASE_URL ?? "https://admin.acontext.app";
217-
const resp = await fetch(`${baseUrl}/admin/v1/project/decrypt`, {
218-
method: "POST",
219-
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
220-
});
221-
if (!resp.ok) {
222-
const body = await resp.json().catch(() => ({}));
223-
throw new Error(body.msg || `HTTP ${resp.status}`);
224-
}
225-
226-
const encodedProjectId = encodeId(projectId);
227-
revalidatePath(`/project/${encodedProjectId}`, "layout");
195+
return toggleProjectEncryption(projectId, apiKey, "encrypt");
196+
}
228197

229-
return { success: true };
230-
} catch (error) {
231-
return {
232-
error: `Failed to decrypt project: ${error instanceof Error ? error.message : "Unknown error"}`,
233-
};
234-
}
198+
export async function decryptProjectAction(
199+
projectId: string,
200+
apiKey: string
201+
): Promise<{ success?: boolean; error?: string }> {
202+
return toggleProjectEncryption(projectId, apiKey, "decrypt");
235203
}
236204

237205
export async function updateProjectConfigs(

src/server/api/go/internal/infra/blob/s3.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ func encryptAndMergeMetadata(content []byte, userKEK []byte, metadata map[string
182182
if err != nil {
183183
return nil, nil, fmt.Errorf("encrypt data: %w", err)
184184
}
185-
// Merge encryption metadata into the existing metadata map
186185
for k, v := range encMeta.MetadataToMap() {
187186
metadata[k] = v
188187
}
@@ -253,14 +252,12 @@ func (u *S3Deps) uploadWithDedup(
253252
datePrefix := time.Now().UTC().Format("2006/01/02")
254253
key := fmt.Sprintf("%s/%s/%s%s", keyPrefix, datePrefix, sumHex, ext)
255254

256-
// Read body into bytes for potential encryption
257255
var bodyBuf bytes.Buffer
258256
if _, err := io.Copy(&bodyBuf, body); err != nil {
259257
return nil, fmt.Errorf("read body: %w", err)
260258
}
261259
uploadBytes := bodyBuf.Bytes()
262260

263-
// Encrypt if userKEK provided
264261
var encErr error
265262
uploadBytes, metadata, encErr = encryptAndMergeMetadata(uploadBytes, userKEK, metadata)
266263
if encErr != nil {
@@ -535,7 +532,6 @@ func (u *S3Deps) EncryptObject(ctx context.Context, key string, userKEK []byte)
535532
return fmt.Errorf("encrypt: %w", err)
536533
}
537534

538-
// Merge encryption metadata
539535
for k, v := range encMeta.MetadataToMap() {
540536
metadata[k] = v
541537
}
@@ -572,9 +568,7 @@ func (u *S3Deps) DecryptObject(ctx context.Context, key string, userKEK []byte)
572568
return fmt.Errorf("decrypt: %w", err)
573569
}
574570

575-
// Remove encryption metadata
576-
delete(metadata, "enc-algo")
577-
delete(metadata, "enc-dek-user")
571+
encryptionpkg.ClearEncryptionMetadata(metadata)
578572

579573
input := &s3.PutObjectInput{
580574
Bucket: aws.String(u.Bucket),
@@ -621,8 +615,7 @@ func (u *S3Deps) RewrapObjectDEK(ctx context.Context, key string, oldKEK, newKEK
621615
return nil
622616
}
623617

624-
// Update metadata
625-
metadata["enc-dek-user"] = newWrapped
618+
metadata[encryptionpkg.MetaKeyDEKUser] = newWrapped
626619

627620
// CopyObject to update metadata in-place
628621
source := u.Bucket + "/" + key

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -248,18 +248,6 @@ func TestRewrapDEK_BothKEKsFail(t *testing.T) {
248248
}
249249
}
250250

251-
func TestEncodeDecodeBase64(t *testing.T) {
252-
original := []byte("hello base64 round trip")
253-
encoded := EncodeBase64(original)
254-
decoded, err := DecodeBase64(encoded)
255-
if err != nil {
256-
t.Fatal(err)
257-
}
258-
if !bytes.Equal(original, decoded) {
259-
t.Fatal("decoded should match original")
260-
}
261-
}
262-
263251
func TestMetadataMapRoundTrip(t *testing.T) {
264252
meta := &EncryptedMeta{
265253
Algo: "AES-256-GCM",

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ func GenerateMasterKey() ([]byte, error) {
4444
return GenerateDEK() // same size: 32 bytes
4545
}
4646

47+
const (
48+
// MetaKeyAlgo is the S3 metadata key for the encryption algorithm.
49+
MetaKeyAlgo = "enc-algo"
50+
// MetaKeyDEKUser is the S3 metadata key for the user-wrapped DEK.
51+
MetaKeyDEKUser = "enc-dek-user"
52+
)
53+
4754
// EncryptedMeta holds the metadata stored alongside an encrypted S3 object.
4855
type EncryptedMeta struct {
4956
Algo string // "AES-256-GCM"
@@ -130,33 +137,29 @@ func RewrapDEK(meta *EncryptedMeta, oldUserKEK, newUserKEK []byte) (string, erro
130137
return base64.StdEncoding.EncodeToString(newWrapped), nil
131138
}
132139

133-
// EncodeBase64 encodes bytes to standard base64 string.
134-
func EncodeBase64(data []byte) string {
135-
return base64.StdEncoding.EncodeToString(data)
136-
}
137-
138-
// DecodeBase64 decodes a standard base64 string to bytes.
139-
func DecodeBase64(s string) ([]byte, error) {
140-
return base64.StdEncoding.DecodeString(s)
141-
}
142-
143140
// MetadataToMap converts EncryptedMeta to S3-compatible metadata map.
144141
func (m *EncryptedMeta) MetadataToMap() map[string]string {
145142
return map[string]string{
146-
"enc-algo": m.Algo,
147-
"enc-dek-user": m.UserWrappedDEK,
143+
MetaKeyAlgo: m.Algo,
144+
MetaKeyDEKUser: m.UserWrappedDEK,
148145
}
149146
}
150147

148+
// ClearFromMap removes encryption metadata keys from the given map.
149+
func ClearEncryptionMetadata(metadata map[string]string) {
150+
delete(metadata, MetaKeyAlgo)
151+
delete(metadata, MetaKeyDEKUser)
152+
}
153+
151154
// MetadataFromMap extracts EncryptedMeta from S3 object metadata.
152155
// Returns nil if the object is not encrypted (no enc-algo key).
153156
func MetadataFromMap(metadata map[string]string) *EncryptedMeta {
154-
algo, ok := metadata["enc-algo"]
157+
algo, ok := metadata[MetaKeyAlgo]
155158
if !ok || algo == "" {
156159
return nil
157160
}
158161
return &EncryptedMeta{
159162
Algo: algo,
160-
UserWrappedDEK: metadata["enc-dek-user"],
163+
UserWrappedDEK: metadata[MetaKeyDEKUser],
161164
}
162165
}

src/server/core/acontext_core/infra/crypto.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,6 @@
1919
KEY_SIZE = 32 # AES-256
2020
NONCE_SIZE = 12 # AES-GCM nonce
2121

22-
# Must match Go constants in internal/infra/crypto/service.go
23-
_USER_KEK_SALT = b"acontext-user-kek"
24-
_USER_KEK_INFO = b"acontext envelope encryption user KEK"
25-
26-
2722
def derive_kek(secret: bytes, salt: bytes, info: bytes) -> bytes:
2823
"""Derive a KEK using HKDF-SHA256 (compatible with Go's golang.org/x/crypto/hkdf)."""
2924
if not secret:
@@ -40,11 +35,15 @@ def derive_kek(secret: bytes, salt: bytes, info: bytes) -> bytes:
4035
def derive_user_kek(api_key_raw: str, pepper: str) -> bytes:
4136
"""Derive the user KEK from the raw API key and pepper.
4237
43-
Mirrors the Go implementation: HKDF-SHA256 over (apiKeyRaw + pepper)
44-
with userKEKSalt and userKEKInfo.
38+
Must match Go's DeriveUserKEK in internal/infra/crypto/service.go:
39+
secret = (authSecret + pepper)
40+
salt = (pepper + "-master-key-wrap")
41+
info = (pepper + " master key wrapping")
4542
"""
4643
secret = (api_key_raw + pepper).encode()
47-
return derive_kek(secret, _USER_KEK_SALT, _USER_KEK_INFO)
44+
salt = (pepper + "-master-key-wrap").encode()
45+
info = (pepper + " master key wrapping").encode()
46+
return derive_kek(secret, salt, info)
4847

4948

5049
def generate_dek() -> bytes:

0 commit comments

Comments
 (0)