Files
seaweedfs/weed/s3api/s3_sse_s3.go
T
Chris Lu 0a91b57f16 fix(s3): encrypt SSE-S3 KEK at rest with AES-GCM wrapping (#8880)
* fix(s3): encrypt SSE-S3 KEK at rest using passphrase-derived wrapping key

* fix(s3): surface KEK migration failures instead of silently dropping them

The legacy-plaintext -> encrypted-at-rest path used to swallow both
wrapKEK and updateKEKContent errors. An operator who configured a
passphrase but had a filer permission issue (or a wrap failure) saw
nothing in the logs and the KEK stayed on disk in plaintext, with the
migration retried on every restart and silently failing every time.

Log each failure path explicitly so the unmigrated state is visible.
The server still starts with the in-memory key loaded — refusing to
boot here would be worse than the warning.

Addresses gemini and coderabbit reviews on PR #8880.

* fix(s3): use a per-installation random salt for KEK wrapping HKDF

The original implementation hardcoded
"seaweedfs-sse-s3-kek-wrapping-v1" as the HKDF salt for the KEK
wrapping key. Two SeaweedFS installations using the same passphrase
therefore produced byte-identical wrapping keys, opening a
precomputation/rainbow-table angle against weaker passphrases.

Generate a random 32-byte salt every time wrapKEK runs and embed it
in the on-disk payload alongside the AES-GCM ciphertext. The new
format is base64(magic("SWv2") || salt || nonce || ciphertext+tag);
unwrapKEK detects the magic and reads the salt out of the payload.
KEKs wrapped under the legacy fixed-salt format still unwrap cleanly
and are opportunistically re-wrapped into v2 on the next load so
operators get the stronger format without manual migration.

Addresses gemini review on PR #8880.

* fix(s3): plumb KEK passphrase from env into the global key manager

InitializeGlobalSSES3KeyManager used to ignore the kekPassphrase
field entirely — the global manager was always constructed with the
empty constructor, so the encrypted-at-rest code path never engaged
in production. Read the passphrase from WEED_S3_SSE_KEK_PASSPHRASE
and apply it before InitializeWithFiler so the load path picks up the
encrypted format. Log a warning when the env var is unset to make the
plaintext fallback visible to operators upgrading from earlier builds.

Adds SetKEKPassphrase as the public seam used by the global init and
by tests, plus regression tests for the wrap/unwrap round-trip,
random-salt independence across managers sharing a passphrase, and
the no-passphrase fallback that preserves the legacy hex-decode path.

Addresses coderabbit review on PR #8880.

* fix(s3): drop redundant base64 decode in KEK migration check

unwrapKEK already does the base64 decode and the magic-prefix check;
isV2WrappedKEK was repeating both passes purely so the migration
branch in loadSuperKeyFromFiler could ask "was this v1 or v2". Have
unwrapKEK return the version flag directly and delete the redundant
helper. Single decode pass per load.

Addresses gemini review on PR #8880.

* fix(s3): updateKEKContent must overwrite, not create

filer_pb.MkFile maps to CreateEntry, which is O_EXCL: it fails with
ErrEntryAlreadyExists when the file already exists. Both KEK migration
paths (legacy v1→v2 rewrap and plaintext→encrypted) call
updateKEKContent against the entry they just read, so MkFile errored
out every time and the migrations only ran in memory while the on-disk
KEK stayed in its old format. The previous commit logged the failure
loudly but the result was the same: a pre-existing deployment never
got migrated.

Switch updateKEKContent to LookupDirectoryEntry + UpdateEntry so the
overwrite actually persists. Surface the lookup/update errors so
the caller's existing "migration failed; KEK still on disk in old
form" warnings fire on the right cases.

Addresses coderabbit critical review on PR #8880.

* chore(s3): drop unused generateAndSaveSuperKeyToFiler

Master removed this as dead code in #8913 and I reintroduced it during
the merge resolution thinking the migration paths still needed it.
On second look it has no callers in this branch — every KEK-creation
path on PR #8880 goes through the existing reader code that handles
"file not present, generate" inline. Drop the duplicate.

Addresses gemini medium review on PR #8880.

* fix(s3): updateKEKContent honours km.kekPath instead of hardcoded path

The migration write path was always pointing at /etc/s3/sse_kek even
when the manager was configured with an operator-overridden kekPath.
Split km.kekPath at the last "/" so the lookup + UpdateEntry land on
the same file the read path used. Defaults match defaultKEKPath when
kekPath is unset.

Addresses gemini medium review on PR #8880.

* fix(s3): KEK passphrase via Viper key, with env-var fallback

The KEK passphrase was read straight from os.Getenv, but every other
SSE-S3 secret (s3.sse.kek, s3.sse.key) goes through Viper so an
operator can set them in security.toml or via WEED_ env vars
interchangeably. Add s3.sse.kek.passphrase to the same path; the
existing SSES3KEKPassphraseEnv lookup stays as a fallback so
deployments wired before this commit keep working.

Addresses gemini medium review on PR #8880.
2026-05-04 19:21:41 -07:00

925 lines
33 KiB
Go

package s3api
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
mathrand "math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"golang.org/x/crypto/hkdf"
"google.golang.org/grpc"
)
// SSE-S3 uses AES-256 encryption with server-managed keys
const (
SSES3Algorithm = s3_constants.SSEAlgorithmAES256
SSES3KeySize = 32 // 256 bits
)
// SSES3Key represents a server-managed encryption key for SSE-S3
type SSES3Key struct {
Key []byte
KeyID string
Algorithm string
IV []byte // Initialization Vector for this key
KeyCommitment []byte // HMAC-SHA256 commitment binding key to IV+algorithm
}
// IsSSES3RequestInternal checks if the request specifies SSE-S3 encryption
func IsSSES3RequestInternal(r *http.Request) bool {
sseHeader := r.Header.Get(s3_constants.AmzServerSideEncryption)
result := sseHeader == SSES3Algorithm
// Debug: log header detection for SSE-S3 requests
if result {
glog.V(4).Infof("SSE-S3 detection: method=%s, header=%q, expected=%q, result=%t, copySource=%q", r.Method, sseHeader, SSES3Algorithm, result, r.Header.Get("X-Amz-Copy-Source"))
}
return result
}
// IsSSES3EncryptedInternal checks if the object metadata indicates SSE-S3 encryption
// An object is considered SSE-S3 encrypted only if it has BOTH the encryption header
// AND the actual encryption key metadata. This prevents false positives when an object
// has leftover headers from a previous encryption state (e.g., after being decrypted
// during a copy operation). Fixes GitHub issue #7562.
func IsSSES3EncryptedInternal(metadata map[string][]byte) bool {
// Check for SSE-S3 algorithm header
sseAlgorithm, hasHeader := metadata[s3_constants.AmzServerSideEncryption]
if !hasHeader || string(sseAlgorithm) != SSES3Algorithm {
return false
}
// Must also have the actual encryption key to be considered encrypted
// Without the key, the object cannot be decrypted and should be treated as unencrypted
_, hasKey := metadata[s3_constants.SeaweedFSSSES3Key]
return hasKey
}
// GenerateSSES3Key generates a new SSE-S3 encryption key
func GenerateSSES3Key() (*SSES3Key, error) {
key := make([]byte, SSES3KeySize)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("failed to generate SSE-S3 key: %w", err)
}
// Generate a key ID for tracking
keyID := fmt.Sprintf("sse-s3-key-%d", mathrand.Int63())
return &SSES3Key{
Key: key,
KeyID: keyID,
Algorithm: SSES3Algorithm,
}, nil
}
// CreateSSES3EncryptedReader creates an encrypted reader for SSE-S3
// Returns the encrypted reader and the IV for metadata storage
func CreateSSES3EncryptedReader(reader io.Reader, key *SSES3Key) (io.Reader, []byte, error) {
// Create AES cipher
block, err := aes.NewCipher(key.Key)
if err != nil {
return nil, nil, fmt.Errorf("create AES cipher: %w", err)
}
// Generate random IV
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, nil, fmt.Errorf("generate IV: %w", err)
}
// Create CTR mode cipher
stream := cipher.NewCTR(block, iv)
// Return encrypted reader and IV separately for metadata storage
encryptedReader := &cipher.StreamReader{S: stream, R: reader}
return encryptedReader, iv, nil
}
// CreateSSES3DecryptedReader creates a decrypted reader for SSE-S3 using IV from metadata
func CreateSSES3DecryptedReader(reader io.Reader, key *SSES3Key, iv []byte) (io.Reader, error) {
// IV comes from object metadata, which is mutable. Validate before passing
// to cipher.NewCTR so a tampered length produces an error rather than the
// crypto/cipher panic the documentation specifies.
if err := ValidateIV(iv, "SSE-S3 IV"); err != nil {
return nil, err
}
// Verify key commitment before decryption if one exists in metadata
if err := VerifyKeyCommitment(key.Key, iv, key.Algorithm, key.KeyCommitment); err != nil {
return nil, err
}
// Create AES cipher
block, err := aes.NewCipher(key.Key)
if err != nil {
return nil, fmt.Errorf("create AES cipher: %w", err)
}
// Create CTR mode cipher with the provided IV
stream := cipher.NewCTR(block, iv)
decryptReader := &cipher.StreamReader{S: stream, R: reader}
// Wrap with closer if the underlying reader implements io.Closer
if closer, ok := reader.(io.Closer); ok {
return &decryptReaderCloser{
Reader: decryptReader,
underlyingCloser: closer,
}, nil
}
return decryptReader, nil
}
// SerializeSSES3Metadata serializes SSE-S3 metadata for storage using envelope encryption
func SerializeSSES3Metadata(key *SSES3Key) ([]byte, error) {
if err := ValidateSSES3Key(key); err != nil {
return nil, err
}
// Encrypt the DEK using the global key manager's super key
keyManager := GetSSES3KeyManager()
encryptedDEK, nonce, err := keyManager.encryptKeyWithSuperKey(key.Key)
if err != nil {
return nil, fmt.Errorf("failed to encrypt DEK: %w", err)
}
metadata := map[string]string{
"algorithm": key.Algorithm,
"keyId": key.KeyID,
"encryptedDEK": base64.StdEncoding.EncodeToString(encryptedDEK),
"nonce": base64.StdEncoding.EncodeToString(nonce),
}
// Include IV if present (needed for chunk-level decryption)
if key.IV != nil {
metadata["iv"] = base64.StdEncoding.EncodeToString(key.IV)
// Compute and store key commitment binding key ↔ IV + algorithm
commitment := ComputeKeyCommitment(key.Key, key.IV, key.Algorithm)
metadata["keyCommitment"] = base64.StdEncoding.EncodeToString(commitment)
}
// Use JSON for proper serialization
data, err := json.Marshal(metadata)
if err != nil {
return nil, fmt.Errorf("marshal SSE-S3 metadata: %w", err)
}
return data, nil
}
// DeserializeSSES3Metadata deserializes SSE-S3 metadata from storage and decrypts the DEK
func DeserializeSSES3Metadata(data []byte, keyManager *SSES3KeyManager) (*SSES3Key, error) {
if len(data) == 0 {
return nil, fmt.Errorf("empty SSE-S3 metadata")
}
// Parse the JSON metadata
var metadata map[string]string
if err := json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse SSE-S3 metadata: %w", err)
}
keyID, exists := metadata["keyId"]
if !exists {
return nil, fmt.Errorf("keyId not found in SSE-S3 metadata")
}
algorithm, exists := metadata["algorithm"]
if !exists {
algorithm = s3_constants.SSEAlgorithmAES256 // Default algorithm
}
// Decode the encrypted DEK and nonce
encryptedDEKStr, exists := metadata["encryptedDEK"]
if !exists {
return nil, fmt.Errorf("encryptedDEK not found in SSE-S3 metadata")
}
encryptedDEK, err := base64.StdEncoding.DecodeString(encryptedDEKStr)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted DEK: %w", err)
}
nonceStr, exists := metadata["nonce"]
if !exists {
return nil, fmt.Errorf("nonce not found in SSE-S3 metadata")
}
nonce, err := base64.StdEncoding.DecodeString(nonceStr)
if err != nil {
return nil, fmt.Errorf("failed to decode nonce: %w", err)
}
// Decrypt the DEK using the key manager
if keyManager == nil {
return nil, fmt.Errorf("key manager is required for SSE-S3 key retrieval")
}
dekBytes, err := keyManager.decryptKeyWithSuperKey(encryptedDEK, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt DEK: %w", err)
}
// Reconstruct the key
key := &SSES3Key{
Key: dekBytes,
KeyID: keyID,
Algorithm: algorithm,
}
// Restore IV if present in metadata (for chunk-level decryption)
if ivStr, exists := metadata["iv"]; exists {
iv, err := base64.StdEncoding.DecodeString(ivStr)
if err != nil {
return nil, fmt.Errorf("failed to decode IV: %w", err)
}
key.IV = iv
}
// Restore key commitment if present (for tamper detection)
if commitStr, exists := metadata["keyCommitment"]; exists {
commitment, err := base64.StdEncoding.DecodeString(commitStr)
if err != nil {
return nil, fmt.Errorf("failed to decode key commitment: %w", err)
}
key.KeyCommitment = commitment
}
return key, nil
}
// SSES3KeyManager manages SSE-S3 encryption keys using envelope encryption
// Instead of storing keys in memory, it uses a super key (KEK) to encrypt/decrypt DEKs
type SSES3KeyManager struct {
mu sync.RWMutex
superKey []byte // 256-bit master key (KEK - Key Encryption Key)
filerClient filer_pb.FilerClient // Filer client for KEK persistence
kekPath string // Path in filer where KEK is stored (e.g., /etc/s3/sse_kek)
kekPassphrase string // If set, KEK is encrypted at rest using a key derived from this passphrase
}
const (
// KEK storage layout on the filer. The migration code paths
// updateKEKContent / generateAndSaveSuperKeyToFiler rely on the directory
// + filename split; defaultKEKPath is the joined form kept for the
// existing reader code.
SSES3KEKDirectory = "/etc/s3"
SSES3KEKParentDir = "/etc"
SSES3KEKDirName = "s3"
SSES3KEKFileName = "sse_kek"
// Legacy KEK path on the filer (backward compatibility)
defaultKEKPath = SSES3KEKDirectory + "/" + SSES3KEKFileName
// security.toml keys (also settable via env vars WEED_S3_SSE_KEK / WEED_S3_SSE_KEY):
//
// s3.sse.kek: hex-encoded 256-bit key, same format as /etc/s3/sse_kek.
// Drop-in replacement for the filer-stored KEK. If /etc/s3/sse_kek also
// exists, the values must match or the server refuses to start.
//
// s3.sse.key: any secret string; a 256-bit key is derived via HKDF-SHA256.
// Cannot be used while /etc/s3/sse_kek exists — the filer file must be
// deleted first (to avoid silently orphaning old data).
sseS3KEKConfigKey = "s3.sse.kek"
sseS3KeyConfigKey = "s3.sse.key"
sseS3KEKPassphraseConfigKey = "s3.sse.kek.passphrase"
)
// legacyKEKWrappingSalt is the fixed salt the original implementation used
// for HKDF derivation. It is retained for backward compatibility — KEKs
// wrapped before per-installation salts shipped (the v1 format below) are
// still unwrappable. New writes always use a random salt.
var legacyKEKWrappingSalt = []byte("seaweedfs-sse-s3-kek-wrapping-v1")
// kekWrappedV2Magic identifies the new on-disk format that prefixes the
// wrapped KEK with a random salt. Seeing this magic at byte 0 of the
// decoded payload tells unwrapKEK to read the per-installation salt
// instead of falling back to legacyKEKWrappingSalt.
var kekWrappedV2Magic = []byte{0x53, 0x57, 0x76, 0x32} // "SWv2"
// kekRandomSaltSize is the per-installation salt length in bytes for HKDF.
// 32 bytes matches the SHA-256 output and is the standard recommendation.
const kekRandomSaltSize = 32
// NewSSES3KeyManager creates a new SSE-S3 key manager with envelope encryption.
// If kekPassphrase is non-empty, the KEK is encrypted at rest using a key derived from it.
func NewSSES3KeyManager(kekPassphrase ...string) *SSES3KeyManager {
km := &SSES3KeyManager{
kekPath: defaultKEKPath,
}
if len(kekPassphrase) > 0 {
km.kekPassphrase = kekPassphrase[0]
}
return km
}
// deriveWrappingKey derives a 256-bit AES key from the configured passphrase
// using HKDF-SHA256 with the supplied salt. Per-installation random salts
// land in the v2 format; the legacy fixed salt is still accepted for KEKs
// that were wrapped before random salts shipped.
func (km *SSES3KeyManager) deriveWrappingKey(salt []byte) ([]byte, error) {
if km.kekPassphrase == "" {
return nil, fmt.Errorf("no KEK passphrase configured")
}
hkdfReader := hkdf.New(sha256.New, []byte(km.kekPassphrase), salt, []byte("kek-wrapping"))
wrappingKey := make([]byte, SSES3KeySize)
if _, err := io.ReadFull(hkdfReader, wrappingKey); err != nil {
return nil, fmt.Errorf("HKDF derive wrapping key: %w", err)
}
return wrappingKey, nil
}
// wrapKEK encrypts the KEK using AES-GCM with a freshly-derived wrapping
// key. Output is base64(magic || salt || nonce || ciphertext+tag) — the
// random salt is the defence against rainbow-table precomputation against a
// shared passphrase, and storing it next to the ciphertext means the
// installation can rotate the passphrase without having to migrate the salt
// separately.
func (km *SSES3KeyManager) wrapKEK(kek []byte) ([]byte, error) {
salt := make([]byte, kekRandomSaltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("generate KEK salt: %w", err)
}
wrappingKey, err := km.deriveWrappingKey(salt)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(wrappingKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
header := make([]byte, 0, len(kekWrappedV2Magic)+len(salt))
header = append(header, kekWrappedV2Magic...)
header = append(header, salt...)
sealed := gcm.Seal(append(header, nonce...), nonce, kek, nil) // magic || salt || nonce || ciphertext+tag
return []byte(base64.StdEncoding.EncodeToString(sealed)), nil
}
// unwrapKEK decrypts a wrapped KEK produced by wrapKEK. Two on-disk formats
// are accepted:
//
// v2 (preferred): magic("SWv2") || salt || nonce || ciphertext+tag — the
// salt is read from the payload before HKDF runs.
// v1 (legacy): nonce || ciphertext+tag — falls back to the fixed
// legacyKEKWrappingSalt; rewrapping into v2 happens via the migration
// path in loadSuperKeyFromFiler.
//
// The returned `isV2` flag tells the caller which format was on disk, so
// the migration path can rewrap legacy entries without re-decoding the
// base64 payload a second time.
func (km *SSES3KeyManager) unwrapKEK(wrapped []byte) (kek []byte, isV2 bool, err error) {
raw, err := base64.StdEncoding.DecodeString(string(wrapped))
if err != nil {
return nil, false, fmt.Errorf("base64 decode wrapped KEK: %w", err)
}
salt := legacyKEKWrappingSalt
payload := raw
if len(raw) > len(kekWrappedV2Magic)+kekRandomSaltSize && bytes.Equal(raw[:len(kekWrappedV2Magic)], kekWrappedV2Magic) {
salt = raw[len(kekWrappedV2Magic) : len(kekWrappedV2Magic)+kekRandomSaltSize]
payload = raw[len(kekWrappedV2Magic)+kekRandomSaltSize:]
isV2 = true
}
wrappingKey, err := km.deriveWrappingKey(salt)
if err != nil {
return nil, false, err
}
block, err := aes.NewCipher(wrappingKey)
if err != nil {
return nil, false, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, false, err
}
if len(payload) < gcm.NonceSize() {
return nil, false, fmt.Errorf("wrapped KEK too short")
}
nonce := payload[:gcm.NonceSize()]
ciphertext := payload[gcm.NonceSize():]
out, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, false, err
}
return out, isV2, nil
}
// deriveKeyFromSecret derives a 256-bit key from an arbitrary secret string
// using HKDF-SHA256. The derivation is deterministic: the same secret always
// produces the same key.
func deriveKeyFromSecret(secret string) ([]byte, error) {
hkdfReader := hkdf.New(sha256.New, []byte(secret), nil, []byte("seaweedfs-sse-s3-kek"))
key := make([]byte, SSES3KeySize)
if _, err := io.ReadFull(hkdfReader, key); err != nil {
return nil, fmt.Errorf("failed to derive key: %w", err)
}
return key, nil
}
// loadFilerKEK tries to load the KEK from /etc/s3/sse_kek on the filer.
// Returns the key bytes on success, nil if the file does not exist or filer
// is not configured, or an error on transient failures (retries internally).
func (km *SSES3KeyManager) loadFilerKEK() ([]byte, error) {
if km.filerClient == nil {
return nil, nil // no filer configured
}
var lastErr error
for i := 0; i < 10; i++ {
err := km.loadSuperKeyFromFiler()
if err == nil {
// loadSuperKeyFromFiler sets km.superKey; grab a copy
key := make([]byte, len(km.superKey))
copy(key, km.superKey)
km.superKey = nil // will be set by caller
return key, nil
}
if errors.Is(err, filer_pb.ErrNotFound) {
return nil, nil // file does not exist
}
lastErr = err
glog.Warningf("SSE-S3 KeyManager: failed to load KEK (attempt %d/10): %v", i+1, err)
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("failed to load KEK from %s after 10 attempts: %w", km.kekPath, lastErr)
}
// InitializeWithFiler initializes the key manager with a filer client.
//
// Key source priority (via security.toml or WEED_ env vars):
// 1. s3.sse.kek (env: WEED_S3_SSE_KEK) — hex-encoded, same format as /etc/s3/sse_kek.
// If the filer file also exists, they must match.
// 2. s3.sse.key (env: WEED_S3_SSE_KEY) — any string; 256-bit key derived via HKDF.
// Refused if /etc/s3/sse_kek exists — delete the filer file first.
// 3. Existing /etc/s3/sse_kek on the filer (backward compat).
// 4. SSE-S3 disabled (fail on first encrypt/decrypt attempt).
func (km *SSES3KeyManager) InitializeWithFiler(filerClient filer_pb.FilerClient) error {
// Set filerClient under lock, then release — the rest may do slow I/O
// (filer retries with sleep) and must not block encrypt/decrypt callers.
km.mu.Lock()
km.filerClient = filerClient
km.mu.Unlock()
v := util.GetViper()
cfgKEK := v.GetString(sseS3KEKConfigKey) // hex-encoded, drop-in for filer file
cfgKey := v.GetString(sseS3KeyConfigKey) // any string, HKDF-derived
if cfgKEK != "" && cfgKey != "" {
return fmt.Errorf("only one of %s and %s may be set, not both", sseS3KEKConfigKey, sseS3KeyConfigKey)
}
var resolvedKey []byte
switch {
// --- Case 1: s3.sse.kek (hex, same format as filer file) ---
case cfgKEK != "":
key, err := hex.DecodeString(cfgKEK)
if err != nil {
return fmt.Errorf("invalid %s: must be hex-encoded: %w", sseS3KEKConfigKey, err)
}
if len(key) != SSES3KeySize {
return fmt.Errorf("invalid %s: must be %d bytes (%d hex chars), got %d bytes",
sseS3KEKConfigKey, SSES3KeySize, SSES3KeySize*2, len(key))
}
// Best-effort consistency check: if the filer file exists, warn on
// mismatch. A temporarily unreachable filer must not block startup
// when the operator has explicitly provided a KEK.
filerKey, err := km.loadFilerKEK()
if err != nil {
glog.Warningf("SSE-S3 KeyManager: could not reach filer to verify %s against %s: %v (proceeding with configured KEK)",
sseS3KEKConfigKey, km.kekPath, err)
} else if filerKey != nil && !bytes.Equal(filerKey, key) {
return fmt.Errorf("%s does not match existing %s — "+
"use the same key value as the filer file, or migrate existing data to the new key. "+
"See the Server-Side-Encryption wiki for migration steps",
sseS3KEKConfigKey, km.kekPath)
}
resolvedKey = key
glog.V(0).Infof("SSE-S3 KeyManager: Loaded KEK from %s config", sseS3KEKConfigKey)
// --- Case 2: s3.sse.key (any string, HKDF-derived) ---
case cfgKey != "":
// If the filer still has a legacy KEK file, the operator must migrate
// existing data first — using a derived key would silently orphan
// objects encrypted with the old KEK.
filerKey, err := km.loadFilerKEK()
if err != nil {
glog.Warningf("SSE-S3 KeyManager: could not reach filer to check for legacy %s: %v (proceeding with configured key)",
km.kekPath, err)
} else if filerKey != nil {
return fmt.Errorf("%s cannot be used while %s exists on the filer — "+
"existing objects are encrypted with the filer KEK. "+
"Migrate to %s first (copy the filer KEK value) or follow the key-rotation steps in the Server-Side-Encryption wiki",
sseS3KeyConfigKey, km.kekPath, sseS3KEKConfigKey)
}
derived, err := deriveKeyFromSecret(cfgKey)
if err != nil {
return err
}
resolvedKey = derived
glog.V(0).Infof("SSE-S3 KeyManager: Derived KEK from %s config", sseS3KeyConfigKey)
// --- Case 3: Load existing filer KEK (backward compatibility) ---
default:
filerKey, err := km.loadFilerKEK()
if err != nil {
return err
}
if filerKey != nil {
resolvedKey = filerKey
glog.V(1).Infof("SSE-S3 KeyManager: Loaded KEK from filer %s", km.kekPath)
glog.V(0).Infof("SSE-S3 KeyManager: Consider setting %s in security.toml instead of storing KEK on filer", sseS3KEKConfigKey)
} else {
// --- Case 4: Nothing configured — SSE-S3 disabled ---
glog.V(0).Infof("SSE-S3 KeyManager: No KEK configured. SSE-S3 encryption is disabled. "+
"Set %s or %s in security.toml to enable it.", sseS3KEKConfigKey, sseS3KeyConfigKey)
}
}
// Only hold the lock to write the final state.
km.mu.Lock()
km.superKey = resolvedKey
km.mu.Unlock()
return nil
}
// loadSuperKeyFromFiler loads the KEK from the filer
func (km *SSES3KeyManager) loadSuperKeyFromFiler() error {
if km.filerClient == nil {
return fmt.Errorf("filer client not initialized")
}
// Get the entry from filer
entry, err := filer_pb.GetEntry(context.Background(), km.filerClient, util.FullPath(km.kekPath))
if err != nil {
return fmt.Errorf("failed to get KEK entry from filer: %w", err)
}
// Read the content
if len(entry.Content) == 0 {
return fmt.Errorf("KEK entry is empty")
}
var key []byte
if km.kekPassphrase != "" {
// Try to unwrap encrypted KEK first
var wasV2 bool
key, wasV2, err = km.unwrapKEK(entry.Content)
if err == nil {
// Successful unwrap: if the payload was the legacy fixed-salt
// format, opportunistically rewrap it under a fresh per-installation
// salt so the next restart picks up the stronger format. The
// version flag comes straight out of unwrapKEK, avoiding a second
// base64 decode pass over the same content.
if !wasV2 {
if rewrapped, wrapErr := km.wrapKEK(key); wrapErr != nil {
glog.Warningf("SSE-S3 KeyManager: failed to rewrap legacy fixed-salt KEK to v2: %v", wrapErr)
} else if updErr := km.updateKEKContent(rewrapped); updErr != nil {
glog.Warningf("SSE-S3 KeyManager: failed to persist v2-rewrapped KEK: %v", updErr)
} else {
glog.V(1).Infof("SSE-S3 KeyManager: migrated KEK from fixed-salt v1 to per-installation salt v2")
}
}
} else {
// Fall back: maybe this is a legacy plaintext hex KEK — try to decode and re-wrap
legacyKey, hexErr := hex.DecodeString(string(entry.Content))
if hexErr != nil || len(legacyKey) != SSES3KeySize {
return fmt.Errorf("failed to unwrap KEK: %w", err)
}
glog.Warningf("SSE-S3 KeyManager: migrating plaintext KEK to encrypted storage")
key = legacyKey
// Re-save in encrypted form. Both failure modes used to be swallowed,
// which left the KEK on disk in plaintext while startup proceeded —
// an operator setting a passphrase saw a silent no-op and no signal
// that the migration had failed. Log loudly so the next restart
// makes the unmigrated state obvious; we still load the in-memory
// key so the server stays up.
wrapped, wrapErr := km.wrapKEK(key)
if wrapErr != nil {
glog.Errorf("SSE-S3 KeyManager: failed to wrap legacy KEK during migration; KEK remains plaintext on filer: %v", wrapErr)
} else if updErr := km.updateKEKContent(wrapped); updErr != nil {
glog.Errorf("SSE-S3 KeyManager: failed to persist wrapped KEK during migration; KEK remains plaintext on filer: %v", updErr)
}
}
} else {
// Legacy plaintext hex mode
glog.Warningf("SSE-S3 KeyManager: KEK stored in plaintext — set a KEK passphrase for encrypted storage")
key, err = hex.DecodeString(string(entry.Content))
if err != nil {
return fmt.Errorf("failed to decode KEK: %w", err)
}
}
if len(key) != SSES3KeySize {
return fmt.Errorf("invalid KEK size: expected %d bytes, got %d", SSES3KeySize, len(key))
}
km.superKey = key
return nil
}
// updateKEKContent overwrites the existing KEK file content in the filer.
// Used by the plaintext→encrypted migration path and by the v1→v2 salt
// rewrap; both run after a successful read of the current KEK, so the
// entry is guaranteed to exist. MkFile uses CreateEntry which fails with
// ErrEntryAlreadyExists when the file is already there — we need
// UpdateEntry instead so the migration actually persists.
//
// Splits km.kekPath at the last "/" so an operator-overridden path is
// honoured. Defaults match defaultKEKPath when km.kekPath is unset.
func (km *SSES3KeyManager) updateKEKContent(content []byte) error {
dir, name := splitKEKPath(km.kekPath)
ctx := context.Background()
return km.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if err != nil {
return fmt.Errorf("lookup KEK entry: %w", err)
}
entry := resp.Entry
if entry == nil {
return fmt.Errorf("KEK entry not found at %s/%s", dir, name)
}
entry.Content = content
if entry.Attributes == nil {
entry.Attributes = &filer_pb.FuseAttributes{}
}
entry.Attributes.FileMode = 0600
entry.Attributes.FileSize = uint64(len(content))
entry.Attributes.Mtime = time.Now().Unix()
return filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
Directory: dir,
Entry: entry,
})
})
}
// splitKEKPath splits an absolute KEK file path into (directory, name).
// Falls back to the default location if the path is empty or has no slash.
func splitKEKPath(p string) (dir, name string) {
if p == "" {
return SSES3KEKDirectory, SSES3KEKFileName
}
idx := strings.LastIndex(p, "/")
if idx <= 0 {
return SSES3KEKDirectory, SSES3KEKFileName
}
return p[:idx], p[idx+1:]
}
// GetOrCreateKey gets an existing key or creates a new one
// With envelope encryption, we always generate a new DEK since we don't store them
func (km *SSES3KeyManager) GetOrCreateKey(keyID string) (*SSES3Key, error) {
// Always generate a new key - we use envelope encryption so no need to cache DEKs
return GenerateSSES3Key()
}
// encryptKeyWithSuperKey encrypts a DEK using the super key (KEK) with AES-GCM
func (km *SSES3KeyManager) encryptKeyWithSuperKey(dek []byte) ([]byte, []byte, error) {
km.mu.RLock()
defer km.mu.RUnlock()
if len(km.superKey) == 0 {
return nil, nil, fmt.Errorf("SSE-S3 encryption is not configured — set %s or %s in security.toml", sseS3KEKConfigKey, sseS3KeyConfigKey)
}
block, err := aes.NewCipher(km.superKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("failed to create GCM: %w", err)
}
// Generate random nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt the DEK
encryptedDEK := gcm.Seal(nil, nonce, dek, nil)
return encryptedDEK, nonce, nil
}
// decryptKeyWithSuperKey decrypts a DEK using the super key (KEK) with AES-GCM
func (km *SSES3KeyManager) decryptKeyWithSuperKey(encryptedDEK, nonce []byte) ([]byte, error) {
km.mu.RLock()
defer km.mu.RUnlock()
if len(km.superKey) == 0 {
return nil, fmt.Errorf("SSE-S3 decryption is not configured — set %s or %s in security.toml", sseS3KEKConfigKey, sseS3KeyConfigKey)
}
block, err := aes.NewCipher(km.superKey)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
if len(nonce) != gcm.NonceSize() {
return nil, fmt.Errorf("invalid nonce size: expected %d, got %d", gcm.NonceSize(), len(nonce))
}
// Decrypt the DEK
dek, err := gcm.Open(nil, nonce, encryptedDEK, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt DEK: %w", err)
}
return dek, nil
}
// StoreKey is now a no-op since we use envelope encryption and don't cache DEKs
// The encrypted DEK is stored in the object metadata, not in the key manager
func (km *SSES3KeyManager) StoreKey(key *SSES3Key) {
// No-op: With envelope encryption, we don't need to store keys in memory
// The DEK is encrypted with the super key and stored in object metadata
}
// GetMasterKey returns a derived key from the master KEK for STS signing
// This uses HKDF to isolate the STS security domain from the SSE-S3 domain
func (km *SSES3KeyManager) GetMasterKey() []byte {
km.mu.RLock()
defer km.mu.RUnlock()
if len(km.superKey) == 0 {
return nil
}
// Derive a separate key for STS to isolate security domains
// We use the KEK as the secret, and "seaweedfs-sts-signing-key" as the info
hkdfReader := hkdf.New(sha256.New, km.superKey, nil, []byte("seaweedfs-sts-signing-key"))
derived := make([]byte, 32) // 256-bit derived key
if _, err := io.ReadFull(hkdfReader, derived); err != nil {
glog.Errorf("Failed to derive STS key: %v", err)
return nil
}
return derived
}
// SSES3KEKPassphraseEnv is the legacy environment variable from which the
// global SSE-S3 key manager picks up its KEK-wrapping passphrase. The Viper
// config key sseS3KEKPassphraseConfigKey ("s3.sse.kek.passphrase") is the
// preferred way to set it — same precedence as s3.sse.kek and s3.sse.key —
// but the env var is honoured as a fallback so deployments that wired only
// the env keep working.
const SSES3KEKPassphraseEnv = "WEED_S3_SSE_KEK_PASSPHRASE"
// Global SSE-S3 key manager instance
var globalSSES3KeyManager = NewSSES3KeyManager()
// SetKEKPassphrase configures the KEK-wrapping passphrase. Must be called
// before InitializeWithFiler — the load path reads the passphrase to decide
// whether to attempt unwrap or fall back to plaintext-hex parsing.
func (km *SSES3KeyManager) SetKEKPassphrase(passphrase string) {
km.mu.Lock()
defer km.mu.Unlock()
km.kekPassphrase = passphrase
}
// GetSSES3KeyManager returns the global SSE-S3 key manager
func GetSSES3KeyManager() *SSES3KeyManager {
return globalSSES3KeyManager
}
// KeyManagerFilerClient wraps wdclient.FilerClient to satisfy filer_pb.FilerClient interface
type KeyManagerFilerClient struct {
*wdclient.FilerClient
grpcDialOption grpc.DialOption
}
func (k *KeyManagerFilerClient) AdjustedUrl(location *filer_pb.Location) string {
return location.Url
}
func (k *KeyManagerFilerClient) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
filerAddress := k.GetCurrentFiler()
if filerAddress == "" {
return fmt.Errorf("no filer available")
}
return pb.WithGrpcFilerClient(streamingMode, 0, filerAddress, k.grpcDialOption, fn)
}
// InitializeGlobalSSES3KeyManager initializes the global key manager with
// filer access. The KEK-wrapping passphrase is sourced from the Viper
// config key s3.sse.kek.passphrase (matching the s3.sse.kek and
// s3.sse.key conventions, settable via security.toml or
// WEED_S3_SSE_KEK_PASSPHRASE env), with a fallback to the bare
// SSES3KEKPassphraseEnv lookup for deployments wired before the Viper key
// existed. If neither is set the KEK falls back to plaintext at-rest
// storage (with a startup warning).
func InitializeGlobalSSES3KeyManager(filerClient *wdclient.FilerClient, grpcDialOption grpc.DialOption) error {
passphrase := util.GetViper().GetString(sseS3KEKPassphraseConfigKey)
if passphrase == "" {
passphrase = os.Getenv(SSES3KEKPassphraseEnv)
}
if passphrase != "" {
globalSSES3KeyManager.SetKEKPassphrase(passphrase)
} else {
glog.Warningf("SSE-S3 KeyManager: neither %s nor %s is set; the KEK will be stored on the filer in plaintext. Set one to enable encrypted-at-rest KEK storage.", sseS3KEKPassphraseConfigKey, SSES3KEKPassphraseEnv)
}
wrapper := &KeyManagerFilerClient{
FilerClient: filerClient,
grpcDialOption: grpcDialOption,
}
return globalSSES3KeyManager.InitializeWithFiler(wrapper)
}
// GetSSES3IV extracts the IV for single-part SSE-S3 objects
// Priority: 1) object-level metadata (for inline/small files), 2) first chunk metadata
func GetSSES3IV(entry *filer_pb.Entry, sseS3Key *SSES3Key, keyManager *SSES3KeyManager) ([]byte, error) {
// First check if IV is in the object-level key (for small/inline files)
if len(sseS3Key.IV) > 0 {
return sseS3Key.IV, nil
}
// Fallback: Get IV from first chunk's metadata (for chunked files)
if len(entry.GetChunks()) > 0 {
chunk := entry.GetChunks()[0]
if len(chunk.GetSseMetadata()) > 0 {
chunkKey, err := DeserializeSSES3Metadata(chunk.GetSseMetadata(), keyManager)
if err != nil {
return nil, fmt.Errorf("failed to deserialize chunk SSE-S3 metadata: %w", err)
}
if len(chunkKey.IV) > 0 {
return chunkKey.IV, nil
}
}
}
return nil, fmt.Errorf("SSE-S3 IV not found in object or chunk metadata")
}
// CreateSSES3EncryptedReaderWithBaseIV creates an encrypted reader using a base IV for multipart upload consistency.
// The returned IV is the offset-derived IV, calculated from the input baseIV and offset.
func CreateSSES3EncryptedReaderWithBaseIV(reader io.Reader, key *SSES3Key, baseIV []byte, offset int64) (io.Reader, []byte /* derivedIV */, error) {
// Validate key to prevent panics and security issues
if key == nil {
return nil, nil, fmt.Errorf("SSES3Key is nil")
}
if key.Key == nil || len(key.Key) != SSES3KeySize {
return nil, nil, fmt.Errorf("invalid SSES3Key: must be %d bytes, got %d", SSES3KeySize, len(key.Key))
}
if err := ValidateSSES3Key(key); err != nil {
return nil, nil, err
}
block, err := aes.NewCipher(key.Key)
if err != nil {
return nil, nil, fmt.Errorf("create AES cipher: %w", err)
}
// Calculate the proper IV with offset to ensure unique IV per chunk/part
// This prevents the severe security vulnerability of IV reuse in CTR mode
// Skip is not used here because we're encrypting from the start (not reading a range)
iv, _ := calculateIVWithOffset(baseIV, offset)
stream := cipher.NewCTR(block, iv)
encryptedReader := &cipher.StreamReader{S: stream, R: reader}
return encryptedReader, iv, nil
}