Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/helm-install-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,15 @@ jobs:
--set image.pullPolicy=Always \
--set "imagePullSecrets[0].name=ghcr-login" \
--set s3.host="http://minio:9000" \
--set secrets.encryptKey=test-encryption-key-for-ci \
--set secrets.awsAccessKeyId=minioadmin \
--set secrets.awsSecretAccessKey=minioadmin \
--set secrets.credentials[0].accessKey=minioadmin \
--set secrets.credentials[0].secretKey=minioadmin \
--set secrets.credentials[0].kek=test-encryption-key-for-ci \
--set redis-ha.enabled=false \
--set externalRedis.url="redis://redis:6379/0" \
--set externalRedis.existingSecret=redis-secret \
--set externalRedis.passwordKey=redis-password \
--set admin.enabled=true \
--set admin.secret=test-admin-secret-for-ci \
--set replicaCount=3 \
--set resources.limits.cpu=100m \
--set resources.requests.cpu=50m \
Expand Down
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,22 @@ S3's server-side encryption is great, but your cloud provider holds the keys. S3

## Install

Each AWS credential is configured with its **own encryption key** (KEK). The proxy verifies the client's signature with the credential's secret key, then encrypts/decrypts that credential's objects with its KEK.

**Option A** — inline secrets (quick start):

```bash
helm install s3proxy oci://ghcr.io/serversidehannes/s3proxy-python/charts/s3proxy-python \
--set secrets.encryptKey="your-32-byte-key" \
--set secrets.awsAccessKeyId="AKIA..." \
--set secrets.awsSecretAccessKey="wJalr..."
--set secrets.credentials[0].accessKey="AKIA..." \
--set secrets.credentials[0].secretKey="wJalr..." \
--set secrets.credentials[0].kek="this-credentials-encryption-secret"
```

**Option B** — existing K8s secret (recommended for production):

```bash
kubectl create secret generic s3proxy-secrets \
--from-literal=S3PROXY_ENCRYPT_KEY="your-32-byte-key" \
--from-literal=AWS_ACCESS_KEY_ID="AKIA..." \
--from-literal=AWS_SECRET_ACCESS_KEY="wJalr..."
--from-literal=S3PROXY_CREDENTIALS='[{"access_key":"AKIA...","secret_key":"wJalr...","kek":"this-credentials-encryption-secret"}]'

helm install s3proxy oci://ghcr.io/serversidehannes/s3proxy-python/charts/s3proxy-python \
--set secrets.existingSecrets.enabled=true \
Expand Down Expand Up @@ -106,6 +106,18 @@ Master Key → KEK (derived via SHA-256)
└→ encrypts data (AES-256-GCM)
```

**Per-credential keys** — Each AWS credential has its own KEK. The proxy verifies the client's signature with the credential's secret key, then wraps that credential's DEKs with the credential's KEK. So a leaked KEK only exposes the data written by that one credential. The access key that wrapped each object is recorded in the object's metadata (`isec-kid`), so **decryption always uses the key that actually encrypted the object** — reconfiguring credentials never orphans existing data, as long as that access key's KEK is still present.

```bash
# Each credential: access_key + secret_key + its own kek (SHA-256'd into the KEK)
S3PROXY_CREDENTIALS='[
{"access_key":"AKIA-ACME","secret_key":"...","kek":"acme-kek-secret"},
{"access_key":"AKIA-GLOBEX","secret_key":"...","kek":"globex-kek-secret"}
]'
```

A request signed by an access key with no configured KEK is rejected. Via Helm: set `secrets.credentials` (see [chart/values.yaml](chart/values.yaml)).

---

## Configuration
Expand All @@ -115,8 +127,9 @@ Master Key → KEK (derived via SHA-256)
| `replicaCount` | `3` | Pod replicas |
| `s3.host` | `s3.amazonaws.com` | S3 endpoint (AWS, MinIO, R2, etc.) |
| `s3.region` | `us-east-1` | AWS region |
| `secrets.encryptKey` | | Encryption key |
| `secrets.credentials` | `[]` | AWS credentials, each `{accessKey, secretKey, kek}` |
| `secrets.existingSecrets.enabled` | `false` | Use existing K8s secret |
| `admin.secret` | `change-me` | Secret signing admin session cookies (when admin UI on) |
| `redis-ha.enabled` | `true` | Deploy embedded Redis HA |
| `gateway.enabled` | `false` | Create gateway service |
| `ingress.enabled` | `false` | Enable ingress |
Expand All @@ -134,8 +147,8 @@ Yes. S3Proxy detects unencrypted objects and returns them as-is. Migrate by copy
</details>

<details>
<summary><strong>What if I lose my encryption key?</strong></summary>
Data is unrecoverable. Back up your key.
<summary><strong>What if I lose an encryption key?</strong></summary>
Data written by that credential is unrecoverable. Each object records the access key that encrypted it, so keep every credential's <code>kek</code> as long as objects written by that credential exist. Back up your keys.
</details>

<details>
Expand All @@ -158,8 +171,8 @@ Yes. The proxy verifies the presigned signature, then makes its own authenticate
## Roadmap

- [ ] Key rotation (re-encrypt objects with a new master key)
- [ ] Multiple AWS credential pairs (per-client auth)
- [ ] Per-bucket / per-prefix encryption keys
- [x] Multiple AWS credential pairs (per-client auth)
- [x] Per-credential encryption keys
- [ ] S3 Select passthrough
- [ ] Ceph S3 compatibility > 80%
- [ ] Batch re-encryption CLI tool
Expand Down
16 changes: 7 additions & 9 deletions chart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

```bash
helm install s3proxy oci://ghcr.io/serversidehannes/s3proxy-python/charts/s3proxy-python \
--set secrets.encryptKey="your-key" \
--set secrets.awsAccessKeyId="AKIA..." \
--set secrets.awsSecretAccessKey="wJalr..."
--set secrets.credentials[0].accessKey="AKIA..." \
--set secrets.credentials[0].secretKey="wJalr..." \
--set secrets.credentials[0].kek="this-credentials-encryption-secret"
```

## Values
Expand All @@ -23,21 +23,19 @@ helm install s3proxy oci://ghcr.io/serversidehannes/s3proxy-python/charts/s3prox
| `server.noTls` | `true` | Disable TLS (in-cluster only) |
| `performance.memoryLimitMb` | `64` | Memory budget for streaming |
| `logLevel` | `DEBUG` | Log level |
| `secrets.encryptKey` | `""` | AES-256 encryption key |
| `secrets.awsAccessKeyId` | `""` | AWS access key |
| `secrets.awsSecretAccessKey` | `""` | AWS secret key |
| `secrets.credentials` | `[]` | AWS credentials, each `{accessKey, secretKey, kek}` — the credential's KEK encrypts its objects |
| `secrets.existingSecrets.enabled` | `false` | Use pre-created K8s secret |
| `secrets.existingSecrets.name` | `""` | Existing secret name |
| `secrets.existingSecrets.keys.encryptKey` | `S3PROXY_ENCRYPT_KEY` | Key name in existing secret |
| `secrets.existingSecrets.keys.awsAccessKeyId` | `AWS_ACCESS_KEY_ID` | Key name in existing secret |
| `secrets.existingSecrets.keys.awsSecretAccessKey` | `AWS_SECRET_ACCESS_KEY` | Key name in existing secret |
| `secrets.existingSecrets.keys.credentials` | `S3PROXY_CREDENTIALS` | Credentials key name in existing secret |
| `admin.enabled` | `false` | Enable the admin dashboard |
| `admin.path` | `/admin` | URL path prefix for the dashboard |
| `admin.username` | `admin` | Dashboard username (stored in the Secret; override in production) |
| `admin.password` | `admin` | Dashboard password (stored in the Secret; override in production) |
| `admin.secret` | `change-me` | Secret signing dashboard session cookies (override in production) |
| `admin.existingSecret.name` | `""` | Pre-created secret holding admin credentials |
| `admin.existingSecret.usernameKey` | `S3PROXY_ADMIN_USERNAME` | Username key in the existing secret |
| `admin.existingSecret.passwordKey` | `S3PROXY_ADMIN_PASSWORD` | Password key in the existing secret |
| `admin.existingSecret.secretKey` | `S3PROXY_ADMIN_SECRET` | Session-secret key in the existing secret |
| `admin.ingress.enabled` | `false` | Dedicated Ingress for the dashboard (keep off unless intentionally exposing it) |
| `admin.ingress.className` | `nginx` | Ingress class for the admin Ingress |
| `admin.ingress.host` | `""` | Hostname for the dashboard (required when enabled) |
Expand Down
19 changes: 7 additions & 12 deletions chart/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,11 @@ spec:
env:
{{- /* App secrets from existing secret */ -}}
{{- if .Values.secrets.existingSecrets.enabled }}
- name: S3PROXY_ENCRYPT_KEY
- name: S3PROXY_CREDENTIALS
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.existingSecrets.name }}
key: {{ .Values.secrets.existingSecrets.keys.encryptKey }}
- name: AWS_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.existingSecrets.name }}
key: {{ .Values.secrets.existingSecrets.keys.awsAccessKeyId }}
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.existingSecrets.name }}
key: {{ .Values.secrets.existingSecrets.keys.awsSecretAccessKey }}
key: {{ .Values.secrets.existingSecrets.keys.credentials }}
{{- end }}
{{- /* Admin dashboard credentials (when not pulled via the chart-secret envFrom) */ -}}
{{- if $adminNeedsEnv }}
Expand All @@ -69,6 +59,11 @@ spec:
secretKeyRef:
name: {{ $adminSecret }}
key: {{ .Values.admin.existingSecret.passwordKey }}
- name: S3PROXY_ADMIN_SECRET
valueFrom:
secretKeyRef:
name: {{ $adminSecret }}
key: {{ .Values.admin.existingSecret.secretKey }}
{{- end }}
{{- /* Redis password from redis-ha secret */ -}}
{{- if and (index .Values "redis-ha" "enabled") (index .Values "redis-ha" "auth") }}
Expand Down
13 changes: 8 additions & 5 deletions chart/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
{{- $createAdmin := and .Values.admin.enabled (not .Values.admin.existingSecret.name) -}}
{{- if or $createApp $createAdmin }}
# Creating static secrets (provide via helm --set or secure values file, not hardcoded)
# Secret keys created: S3PROXY_ENCRYPT_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
# and (when the admin dashboard is enabled) S3PROXY_ADMIN_USERNAME / S3PROXY_ADMIN_PASSWORD
# Secret keys created: S3PROXY_CREDENTIALS, and (when the admin dashboard is enabled)
# S3PROXY_ADMIN_USERNAME / S3PROXY_ADMIN_PASSWORD / S3PROXY_ADMIN_SECRET
apiVersion: v1
kind: Secret
metadata:
Expand All @@ -13,12 +13,15 @@ metadata:
type: Opaque
stringData:
{{- if $createApp }}
S3PROXY_ENCRYPT_KEY: {{ .Values.secrets.encryptKey | quote }}
AWS_ACCESS_KEY_ID: {{ .Values.secrets.awsAccessKeyId | quote }}
AWS_SECRET_ACCESS_KEY: {{ .Values.secrets.awsSecretAccessKey | quote }}
{{- $creds := list }}
{{- range .Values.secrets.credentials }}
{{- $creds = append $creds (dict "access_key" .accessKey "secret_key" .secretKey "kek" .kek) }}
{{- end }}
S3PROXY_CREDENTIALS: {{ toJson $creds | quote }}
{{- end }}
{{- if $createAdmin }}
S3PROXY_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
S3PROXY_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
S3PROXY_ADMIN_SECRET: {{ .Values.admin.secret | quote }}
{{- end }}
{{- end }}
19 changes: 13 additions & 6 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,16 @@ secrets:
enabled: false
name: ""
keys:
encryptKey: "S3PROXY_ENCRYPT_KEY"
awsAccessKeyId: "AWS_ACCESS_KEY_ID"
awsSecretAccessKey: "AWS_SECRET_ACCESS_KEY"
credentials: "S3PROXY_CREDENTIALS"

encryptKey: ""
awsAccessKeyId: ""
awsSecretAccessKey: ""
# AWS credentials, each with its own encryption key (KEK). The proxy verifies
# a client's signature with secretKey, then encrypts/decrypts that credential's
# objects with the KEK derived from kek. Rendered into the S3PROXY_CREDENTIALS
# secret as a JSON array. At least one entry is required.
credentials: []
# - accessKey: "AKIAEXAMPLE"
# secretKey: "wJalr...EXAMPLEKEY"
# kek: "a-long-random-kek-secret"

logLevel: "DEBUG"

Expand All @@ -105,10 +108,14 @@ admin:
path: "/admin"
username: "admin"
password: "admin"
# Stable secret used to sign admin session cookies. Required when enabled.
# Override in production; keep it stable so sessions survive pod restarts.
secret: "change-me"
existingSecret:
name: ""
usernameKey: "S3PROXY_ADMIN_USERNAME"
passwordKey: "S3PROXY_ADMIN_PASSWORD"
secretKey: "S3PROXY_ADMIN_SECRET"

# Optional dedicated Ingress for the dashboard, separate from the S3 data-plane
# ingress above. OFF by default — the admin UI should not be internet-facing
Expand Down
9 changes: 5 additions & 4 deletions e2e/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ services:
echo "ECK operator apply failed, retry $$i..."
sleep 5
done
kubectl wait --namespace elastic-system --for=condition=ready pod --selector=control-plane=elastic-operator --timeout=300s
kubectl rollout status --namespace elastic-system statefulset/elastic-operator --timeout=300s
echo "✓ ECK operator ready"
) &
ECK_PID=$$!
Expand Down Expand Up @@ -413,9 +413,10 @@ services:
--set image.repository=s3proxy \
--set image.pullPolicy=IfNotPresent \
--set s3.host="http://minio.minio.svc.cluster.local:9000" \
--set secrets.encryptKey="$$ENCRYPT_KEY" \
--set secrets.awsAccessKeyId="minioadmin" \
--set secrets.awsSecretAccessKey="minioadmin" \
--set secrets.credentials[0].accessKey="minioadmin" \
--set secrets.credentials[0].secretKey="minioadmin" \
--set secrets.credentials[0].kek="$$ENCRYPT_KEY" \
--set admin.secret="$$ENCRYPT_KEY" \
--set logLevel="DEBUG" \
--set performance.memoryLimitMb=64 \
--set gateway.enabled=true \
Expand Down
4 changes: 2 additions & 2 deletions s3proxy/admin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def __init__(self, settings: Settings, credentials_store: dict[str, str]):
)
self.username = settings.admin_username
self.password = settings.admin_password
# Derive a session-signing secret from the KEK so cookies survive pod restarts.
self.session_secret = hashlib.sha256(b"s3proxy-admin-session|" + settings.kek).digest()
# Stable session-signing secret (survives pod restarts, shared across replicas).
self.session_secret = settings.admin_session_secret

def valid(self, username: str, password: str) -> bool:
return secrets.compare_digest(username.encode(), self.username.encode()) and (
Expand Down
23 changes: 14 additions & 9 deletions s3proxy/admin/collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,15 +338,20 @@ def _derive_buckets(entries: list[RequestEntry]) -> list[dict]:


def _derive_keys(settings: Settings) -> list[dict]:
fp = hashlib.sha256(settings.kek).hexdigest()[:8]
return [
{
"id": f"key-{fp}",
"type": "Local (KEK)",
"status": "Active",
"created": "—",
}
]
# One KEK per configured AWS login (access key). The kek secret itself is
# never exposed - only a short fingerprint for identification.
keys = []
for entry in settings.credentials:
fp = hashlib.sha256(entry.kek.encode()).hexdigest()[:8]
keys.append(
{
"id": entry.access_key,
"type": f"Local (KEK · {fp})",
"status": "Active",
"created": "—",
}
)
return keys


# ---------------------------------------------------------------------------
Expand Down
12 changes: 1 addition & 11 deletions s3proxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,6 @@
logger: BoundLogger = structlog.get_logger(__name__).bind(pod=pod_name)


def load_credentials() -> dict[str, str]:
"""Load AWS credentials from environment variables."""
credentials_store: dict[str, str] = {}
access_key = os.environ.get("AWS_ACCESS_KEY_ID", "")
secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
if access_key and secret_key:
credentials_store[access_key] = secret_key
return credentials_store


def create_lifespan(settings: Settings, credentials_store: dict[str, str]) -> AsyncIterator[None]:
"""Create lifespan context manager for FastAPI app.

Expand Down Expand Up @@ -103,7 +93,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
Configured FastAPI application instance.
"""
settings = settings or Settings()
credentials_store = load_credentials()
credentials_store = settings.credentials_store

lifespan = create_lifespan(settings, credentials_store)
app = FastAPI(title="S3Proxy", lifespan=lifespan, docs_url=None, redoc_url=None)
Expand Down
Loading