Skip to content

Commit 5805c1c

Browse files
GenerQAQclaude
andcommitted
feat: add S3 envelope encryption for data-at-rest protection
Implement envelope encryption (AES-256-GCM) for all S3-stored data including user files, session messages, and attachments. Each object gets a unique DEK wrapped by both user KEK (derived from API key) and admin KEK (derived from master key), enabling per-project key rotation without re-encrypting data. Key changes: - Add crypto package with HKDF key derivation and AES-256-GCM envelope encryption - Modify S3 upload/download paths in both Go API and Python Core to encrypt/decrypt - Auth middleware derives user KEK from API key and injects into gin context - Replace presigned URL downloads with API-proxied streaming (server-side decryption) - Update TS/PY SDKs to use new API proxy download instead of presigned URLs - Backward compatible: unencrypted legacy objects still readable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bc630a8 commit 5805c1c

32 files changed

Lines changed: 1104 additions & 101 deletions

File tree

src/client/acontext-py/src/acontext/agent/disk.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,11 @@ def execute(self, ctx: DiskContext, llm_arguments: dict) -> str:
455455
expire=expire,
456456
)
457457

458-
if not result.public_url:
459-
raise RuntimeError("Failed to get public URL: server did not return a URL.")
458+
if result.public_url:
459+
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
460460

461-
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
461+
# Encryption enabled — no presigned URL
462+
return f"File '{normalized_path}{filename}' is available. Use the download API endpoint to retrieve its content."
462463

463464
async def async_execute(self, ctx: AsyncDiskContext, llm_arguments: dict) -> str:
464465
"""Get a public download URL for a file (async)."""
@@ -478,10 +479,10 @@ async def async_execute(self, ctx: AsyncDiskContext, llm_arguments: dict) -> str
478479
expire=expire,
479480
)
480481

481-
if not result.public_url:
482-
raise RuntimeError("Failed to get public URL: server did not return a URL.")
482+
if result.public_url:
483+
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
483484

484-
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
485+
return f"File '{normalized_path}{filename}' is available. Use the download API endpoint to retrieve its content."
485486

486487

487488
class GrepArtifactsTool(BaseTool):

src/client/acontext-py/src/acontext/resources/async_skills.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@ async def download(
170170

171171
if resp.content is not None:
172172
file_dest.write_text(resp.content.raw, encoding="utf-8")
173+
elif resp.raw_content is not None:
174+
import base64
175+
file_dest.write_bytes(base64.b64decode(resp.raw_content))
173176
elif resp.url is not None:
174177
async with httpx.AsyncClient() as http:
175178
r = await http.get(resp.url)

src/client/acontext-py/src/acontext/resources/skills.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,9 @@ def download(
172172

173173
if resp.content is not None:
174174
file_dest.write_text(resp.content.raw, encoding="utf-8")
175+
elif resp.raw_content is not None:
176+
import base64
177+
file_dest.write_bytes(base64.b64decode(resp.raw_content))
175178
elif resp.url is not None:
176179
r = httpx.get(resp.url)
177180
r.raise_for_status()

src/client/acontext-py/src/acontext/types/skill.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ class GetSkillFileResp(BaseModel):
6464
None,
6565
description="Parsed file content if available (present if file is parseable)",
6666
)
67+
raw_content: str | None = Field(
68+
None,
69+
description="Base64-encoded binary content (present when encryption is enabled for non-text files)",
70+
)
71+
content_mime: str | None = Field(
72+
None,
73+
description="MIME type of raw_content",
74+
)
6775

6876

6977
class DownloadSkillToSandboxResp(BaseModel):

src/client/acontext-ts/src/agent/disk.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,12 @@ export class DownloadFileTool extends AbstractBaseTool {
290290
expire,
291291
});
292292

293-
if (!result.public_url) {
294-
throw new Error('Failed to get public URL: server did not return a URL.');
293+
if (result.public_url) {
294+
return `Public download URL for '${normalizedPath}${filename}' (expires in ${expire}s):\n${result.public_url}`;
295295
}
296296

297-
return `Public download URL for '${normalizedPath}${filename}' (expires in ${expire}s):\n${result.public_url}`;
297+
// Encryption enabled — no presigned URL, content available via API proxy
298+
return `File '${normalizedPath}${filename}' is available. Use the download API endpoint to retrieve its content.`;
298299
}
299300
}
300301

src/client/acontext-ts/src/resources/skills.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ export class SkillsAPI {
156156

157157
if (resp.content) {
158158
await fs.writeFile(fileDest, resp.content.raw, 'utf-8');
159+
} else if (resp.raw_content) {
160+
// Binary content returned as base64 (when encryption is enabled)
161+
const buffer = Buffer.from(resp.raw_content, 'base64');
162+
await fs.writeFile(fileDest, buffer);
159163
} else if (resp.url) {
160164
const r = await fetch(resp.url);
161165
if (!r.ok) {

src/client/acontext-ts/src/types/skill.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export const GetSkillFileRespSchema = z.object({
4747
mime: z.string(),
4848
url: z.string().nullable().optional(),
4949
content: FileContentSchema.nullable().optional(),
50+
raw_content: z.string().nullable().optional(), // base64-encoded binary content (when encryption enabled)
51+
content_mime: z.string().nullable().optional(),
5052
});
5153

5254
export type GetSkillFileResp = z.infer<typeof GetSkillFileRespSchema>;

src/server/api/go/cmd/server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/memodb-io/Acontext/internal/bootstrap"
2626
"github.com/memodb-io/Acontext/internal/config"
2727
"github.com/memodb-io/Acontext/internal/infra/cache"
28+
encryptionpkg "github.com/memodb-io/Acontext/internal/infra/crypto"
2829
dbpkg "github.com/memodb-io/Acontext/internal/infra/db"
2930
"github.com/memodb-io/Acontext/internal/modules/handler"
3031
"github.com/memodb-io/Acontext/internal/pkg/tokenizer"
@@ -93,11 +94,13 @@ func main() {
9394
learningSpaceHandler := do.MustInvoke[*handler.LearningSpaceHandler](inj)
9495
sessionEventHandler := do.MustInvoke[*handler.SessionEventHandler](inj)
9596
projectHandler := do.MustInvoke[*handler.ProjectHandler](inj)
97+
encSvc := do.MustInvoke[*encryptionpkg.EncryptionService](inj)
9698

9799
engine := router.NewRouter(router.RouterDeps{
98100
Config: cfg,
99101
DB: db,
100102
Log: log,
103+
EncryptionService: encSvc,
101104
SessionHandler: sessionHandler,
102105
DiskHandler: diskHandler,
103106
ArtifactHandler: artifactHandler,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,7 @@ telemetry:
5353

5454
artifact:
5555
maxUploadSizeBytes: ${ARTIFACT_MAX_UPLOAD_SIZE_BYTES} # Default 16MB (16 * 1024 * 1024 bytes)
56+
57+
encryption:
58+
enabled: ${ENCRYPTION_ENABLED} # Set to true to enable S3 envelope encryption
59+
masterKey: "${ENCRYPTION_MASTER_KEY}" # Admin master key (required when enabled)

src/server/api/go/internal/bootstrap/container.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/memodb-io/Acontext/internal/config"
1212
"github.com/memodb-io/Acontext/internal/infra/blob"
1313
"github.com/memodb-io/Acontext/internal/infra/cache"
14+
encryptionpkg "github.com/memodb-io/Acontext/internal/infra/crypto"
1415
"github.com/memodb-io/Acontext/internal/infra/db"
1516
"github.com/memodb-io/Acontext/internal/infra/httpclient"
1617
"github.com/memodb-io/Acontext/internal/infra/logger"
@@ -185,10 +186,17 @@ func BuildContainer() *do.Injector {
185186
return mq.NewPublisher(conn, log, cfg, dialFn)
186187
})
187188

189+
// Encryption
190+
do.Provide(inj, func(i *do.Injector) (*encryptionpkg.EncryptionService, error) {
191+
cfg := do.MustInvoke[*config.Config](i)
192+
return encryptionpkg.NewEncryptionService(cfg.Encryption.MasterKey, cfg.Encryption.Enabled)
193+
})
194+
188195
// S3
189196
do.Provide(inj, func(i *do.Injector) (*blob.S3Deps, error) {
190197
cfg := do.MustInvoke[*config.Config](i)
191-
return blob.NewS3(context.Background(), cfg)
198+
encSvc := do.MustInvoke[*encryptionpkg.EncryptionService](i)
199+
return blob.NewS3(context.Background(), cfg, encSvc)
192200
})
193201
// get presign expire duration
194202
do.Provide(inj, func(i *do.Injector) (func() time.Duration, error) {

0 commit comments

Comments
 (0)