feat(s3): support WEED_S3_SSE_KEY env var for SSE-S3 KEK (#8904)

* feat(s3): support WEED_S3_SSE_KEY env var for SSE-S3 KEK

Add support for providing the SSE-S3 Key Encryption Key (KEK) via the
WEED_S3_SSE_KEY environment variable (hex-encoded 256-bit key). This
avoids storing the master key in plaintext on the filer at /etc/s3/sse_kek.

Key source priority:
1. WEED_S3_SSE_KEY environment variable (recommended)
2. Existing filer KEK at /etc/s3/sse_kek (backward compatible)
3. Auto-generate and save to filer (deprecated for new deployments)

Existing deployments with a filer-stored KEK continue to work unchanged.
A deprecation warning is logged when auto-generating a new filer KEK.

* refactor(s3): derive KEK from any string via HKDF instead of requiring hex

Accept any secret string in WEED_S3_SSE_KEY and derive a 256-bit key
using HKDF-SHA256 instead of requiring a hex-encoded key. This is
simpler for users — no need to generate hex, just set a passphrase.

* feat(s3): add WEED_S3_SSE_KEK and WEED_S3_SSE_KEY env vars for KEK

Two env vars for providing the SSE-S3 Key Encryption Key:

- WEED_S3_SSE_KEK: hex-encoded, same format as /etc/s3/sse_kek.
  If the filer file also exists, they must match.
- WEED_S3_SSE_KEY: any string, 256-bit key derived via HKDF-SHA256.
  Refuses to start if /etc/s3/sse_kek exists (must delete first).

Only one may be set. Existing filer-stored KEKs continue to work.
Auto-generating and storing new KEKs on filer is deprecated.

* fix(s3): stop auto-generating KEK, fail only when SSE-S3 is used

Instead of auto-generating a KEK and storing it on the filer when no
key source is configured, simply leave SSE-S3 disabled. Encrypt and
decrypt operations return a clear error directing the user to set
WEED_S3_SSE_KEK or WEED_S3_SSE_KEY.

* refactor(s3): move SSE-S3 KEK config to security.toml

Move KEK configuration from standalone env vars to security.toml's new
[sse_s3] section, following the same pattern as JWT keys and TLS certs.

  [sse_s3]
  kek = ""   # hex-encoded 256-bit key (same format as /etc/s3/sse_kek)
  key = ""   # any string, HKDF-derived

Viper's WEED_ prefix auto-mapping provides env var support:
WEED_SSE_S3_KEK and WEED_SSE_S3_KEY.

All existing behavior is preserved: filer KEK fallback, mismatch
detection, and HKDF derivation.

* refactor(s3): rename SSE-S3 config keys to s3.sse.kek / s3.sse.key

Use [s3.sse] section in security.toml, matching the existing naming
convention (e.g. [s3.*]). Env vars: WEED_S3_SSE_KEK, WEED_S3_SSE_KEY.

* fix(s3): address code review findings for SSE-S3 KEK

- Don't hold mutex during filer retry loop (up to 20s of sleep).
  Lock only to write filerClient and superKey.
- Remove dead generateAndSaveSuperKeyToFiler and unused constants.
- Return error from deriveKeyFromSecret instead of ignoring it.
- Fix outdated doc comment on InitializeWithFiler.
- Use t.Setenv in tests instead of manual os.Setenv/Unsetenv.

* fix(s3): don't block startup on filer errors when KEK is configured

- When s3.sse.kek is set, a temporarily unreachable filer no longer
  prevents startup. The filer consistency check becomes best-effort
  with a warning.
- Same treatment for s3.sse.key: filer unreachable logs a warning
  instead of failing.
- Rewrite error messages to suggest migration instead of file deletion,
  avoiding the risk of orphaning encrypted data.

Finding 3 (restore auto-generation) intentionally skipped — auto-gen
was removed by design to avoid storing plaintext KEK on filer.

* fix(test): set WEED_S3_SSE_KEY in SSE integration test server startup

SSE-S3 no longer auto-generates a KEK, so integration tests must
provide one. Set WEED_S3_SSE_KEY=test-sse-s3-key in all weed mini
invocations in the test Makefile.
This commit is contained in:
Chris Lu
2026-04-03 13:01:21 -07:00
committed by GitHub
parent 2e98902f29
commit 8fad85aed7
4 changed files with 290 additions and 71 deletions

View File

@@ -2,6 +2,7 @@ package s3api
import (
"bytes"
"encoding/hex"
"fmt"
"io"
"net/http"
@@ -11,6 +12,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// TestSSES3EncryptionDecryption tests basic SSE-S3 encryption and decryption
@@ -681,6 +683,125 @@ func TestSSES3InvalidMetadataDeserialization(t *testing.T) {
}
}
// setViperKey is a test helper that sets a config key via its WEED_ env var.
func setViperKey(t *testing.T, key, value string) {
t.Helper()
util.GetViper().SetDefault(key, "")
t.Setenv("WEED_"+strings.ReplaceAll(strings.ToUpper(key), ".", "_"), value)
}
// TestSSES3KEKConfig tests that sse_s3.kek (hex format) is used as KEK
func TestSSES3KEKConfig(t *testing.T) {
testKey := make([]byte, 32)
for i := range testKey {
testKey[i] = byte(i + 50)
}
setViperKey(t, sseS3KEKConfigKey, hex.EncodeToString(testKey))
km := NewSSES3KeyManager()
err := km.InitializeWithFiler(nil)
if err != nil {
t.Fatalf("InitializeWithFiler failed: %v", err)
}
if !bytes.Equal(km.superKey, testKey) {
t.Errorf("superKey mismatch: expected %x, got %x", testKey, km.superKey)
}
// Round-trip DEK encryption
dek := make([]byte, 32)
for i := range dek {
dek[i] = byte(i)
}
encrypted, nonce, err := km.encryptKeyWithSuperKey(dek)
if err != nil {
t.Fatalf("encryptKeyWithSuperKey failed: %v", err)
}
decrypted, err := km.decryptKeyWithSuperKey(encrypted, nonce)
if err != nil {
t.Fatalf("decryptKeyWithSuperKey failed: %v", err)
}
if !bytes.Equal(decrypted, dek) {
t.Error("round-trip DEK mismatch")
}
}
// TestSSES3KEKConfigInvalidHex tests rejection of bad hex
func TestSSES3KEKConfigInvalidHex(t *testing.T) {
setViperKey(t, sseS3KEKConfigKey, "not-valid-hex")
km := NewSSES3KeyManager()
err := km.InitializeWithFiler(nil)
if err == nil {
t.Fatal("expected error for invalid hex, got nil")
}
if !strings.Contains(err.Error(), "hex-encoded") {
t.Errorf("expected hex error, got: %v", err)
}
}
// TestSSES3KEKConfigWrongSize tests rejection of wrong-size hex key
func TestSSES3KEKConfigWrongSize(t *testing.T) {
setViperKey(t, sseS3KEKConfigKey, hex.EncodeToString(make([]byte, 16)))
km := NewSSES3KeyManager()
err := km.InitializeWithFiler(nil)
if err == nil {
t.Fatal("expected error for wrong key size, got nil")
}
if !strings.Contains(err.Error(), "32 bytes") {
t.Errorf("expected size error, got: %v", err)
}
}
// TestSSES3KeyConfig tests that sse_s3.key (any string, HKDF) works
func TestSSES3KeyConfig(t *testing.T) {
setViperKey(t, sseS3KeyConfigKey, "my-secret-passphrase")
km := NewSSES3KeyManager()
err := km.InitializeWithFiler(nil)
if err != nil {
t.Fatalf("InitializeWithFiler failed: %v", err)
}
if len(km.superKey) != SSES3KeySize {
t.Fatalf("expected %d-byte superKey, got %d", SSES3KeySize, len(km.superKey))
}
// Deterministic: same input → same output
expected, err := deriveKeyFromSecret("my-secret-passphrase")
if err != nil {
t.Fatalf("deriveKeyFromSecret failed: %v", err)
}
if !bytes.Equal(km.superKey, expected) {
t.Errorf("superKey mismatch: expected %x, got %x", expected, km.superKey)
}
}
// TestSSES3KeyConfigDifferentSecrets tests different strings produce different keys
func TestSSES3KeyConfigDifferentSecrets(t *testing.T) {
k1, _ := deriveKeyFromSecret("secret-one")
k2, _ := deriveKeyFromSecret("secret-two")
if bytes.Equal(k1, k2) {
t.Error("different secrets should produce different keys")
}
}
// TestSSES3BothConfigsReject tests that setting both config keys is rejected
func TestSSES3BothConfigsReject(t *testing.T) {
setViperKey(t, sseS3KEKConfigKey, hex.EncodeToString(make([]byte, 32)))
setViperKey(t, sseS3KeyConfigKey, "some-passphrase")
km := NewSSES3KeyManager()
err := km.InitializeWithFiler(nil)
if err == nil {
t.Fatal("expected error when both configs set, got nil")
}
if !strings.Contains(err.Error(), "only one") {
t.Errorf("expected 'only one' error, got: %v", err)
}
}
// TestGetSSES3Headers tests SSE-S3 header generation
func TestGetSSES3Headers(t *testing.T) {
headers := GetSSES3Headers()