This is an automated email from the ASF dual-hosted git repository. github-actions[bot] pushed a commit to tag nightly in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit ae69e8b8025ee30b4e31a3f804ccf85f89d8ae9b Author: Joy <[email protected]> AuthorDate: Wed Aug 19 11:53:24 2026 -0400 feat(channel): encrypt API keys at rest with AES-256-GCM (#211) --- conf/app.conf | 8 ++++ object/channel.go | 56 +++++++++++++++++++++++++++- util/crypto.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++ util/crypto_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 1 deletion(-) diff --git a/conf/app.conf b/conf/app.conf index e00c500..00567e7 100644 --- a/conf/app.conf +++ b/conf/app.conf @@ -18,6 +18,14 @@ dbName = casbin_gateway ; Optional Redis for sessions. Sessions are kept in ./tmp files when empty. redisEndpoint = +; Optional AES-256-GCM encryption for channel API keys at rest. When set, keys +; are encrypted before they are written to the database (the value is hashed to +; a 32-byte key, so any length works). Leave it empty to keep storing keys as +; plaintext. Existing plaintext keys keep working and are upgraded to ciphertext +; the next time the key is re-entered and saved. Changing or losing this key +; makes already-encrypted keys unreadable. +apiKeyEncryptionKey = + ; The reverse-proxy gateway, i.e. the WAF itself. It is off by default because ; ports 80 and 443 usually belong to something else on the host, and binding ; them needs root on Linux and macOS. Sites and rules can still be configured diff --git a/object/channel.go b/object/channel.go index b11489c..45fc70c 100644 --- a/object/channel.go +++ b/object/channel.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/apache/casbin-gateway/conf" "github.com/apache/casbin-gateway/proxy" "github.com/apache/casbin-gateway/util" "github.com/xorm-io/core" @@ -39,6 +40,44 @@ var ErrNoChannelAvailable = errors.New("no available channel") // (including an empty string) overwrites the stored key. const ApiKeyMask = "***" +// apiKeyEncryptionSecret is the configured secret used to encrypt channel API +// keys at rest. When empty, keys are stored as plaintext (the previous +// behavior), so encryption can be enabled without a migration. +func apiKeyEncryptionSecret() string { + return conf.GetConfigString("apiKeyEncryptionKey") +} + +// encryptApiKey encrypts channel.ApiKey in place before it is written to the +// database. It is a no-op when no key is configured or the value is empty. +func encryptApiKey(channel *Channel) error { + encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), channel.ApiKey) + if err != nil { + return err + } + channel.ApiKey = encrypted + return nil +} + +// decryptChannel restores the plaintext ApiKey on a channel just read from the +// database, so every caller above this layer works with the real key. A legacy +// plaintext value is passed through unchanged. A decryption failure (e.g. the +// key was rotated) leaves the stored value in place rather than dropping the +// channel: the masking layer still hides it, and any upstream call fails loudly. +func decryptChannel(channel *Channel) { + if channel == nil { + return + } + if plain, err := util.DecryptWithKey(apiKeyEncryptionSecret(), channel.ApiKey); err == nil { + channel.ApiKey = plain + } +} + +func decryptChannels(channels []*Channel) { + for _, channel := range channels { + decryptChannel(channel) + } +} + const ( maxChannelModels = 200 maxChannelModelChars = 100 @@ -76,7 +115,9 @@ type Channel struct { DisplayName string `xorm:"varchar(100)" json:"displayName"` Type string `xorm:"varchar(100)" json:"type"` BaseUrl string `xorm:"varchar(255)" json:"baseUrl"` - // TODO(1.3): ApiKey is stored as plaintext; AES encryption will be added in milestone 1.3. + // ApiKey is encrypted at rest with AES-256-GCM when "apiKeyEncryptionKey" is + // set in app.conf (see encryptApiKey/decryptApiKey). The column also holds the + // base64 ciphertext, so it is wider than a bare key. ApiKey string `xorm:"varchar(500)" json:"apiKey"` // Models is JSON-serialized by xorm, so it needs a text column rather than // a varchar: the serialized form is longer than the joined model names. @@ -94,6 +135,7 @@ func GetChannels(owner string) ([]*Channel, error) { channels := []*Channel{} session := GetSession(owner, -1, -1, "", "", "", "") err := session.Find(&channels) + decryptChannels(channels) return channels, err } @@ -106,6 +148,7 @@ func GetPaginationChannels(owner string, offset, limit int, field, value, sortFi channels := []*Channel{} session := GetSession(owner, offset, limit, field, value, sortField, sortOrder) err := session.Find(&channels) + decryptChannels(channels) return channels, err } @@ -118,6 +161,7 @@ func getChannel(owner, name string) (*Channel, error) { if !existed { return nil, nil } + decryptChannel(channel) return channel, nil } @@ -231,6 +275,10 @@ func AddChannel(channel *Channel) (bool, error) { } channel.UpdatedTime = now + if err := encryptApiKey(channel); err != nil { + return false, err + } + affected, err := ormer.Engine.Insert(channel) return affected != 0, err } @@ -257,6 +305,9 @@ func UpdateChannel(id string, channel *Channel) (bool, error) { // is what makes clearing a key possible. if channel.ApiKey == ApiKeyMask { session = session.Omit("api_key") + } else if err := encryptApiKey(channel); err != nil { + // A real (new or cleared) key is being written, so encrypt it first. + return false, err } affected, err := session.AllCols().Update(channel) @@ -351,6 +402,9 @@ func GetChannelsByModel(model string) ([]*Channel, error) { } } + // The proxy sends these keys upstream, so hand back the decrypted values. + decryptChannels(matchedChannels) + if len(matchedChannels) == 0 { return nil, fmt.Errorf("%w: %s", ErrNoChannelAvailable, model) } diff --git a/util/crypto.go b/util/crypto.go new file mode 100644 index 0000000..828e80e --- /dev/null +++ b/util/crypto.go @@ -0,0 +1,105 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "strings" +) + +// encPrefix marks a value produced by EncryptWithKey. It lets DecryptWithKey +// tell ciphertext apart from a legacy plaintext value written before encryption +// was enabled, so existing rows keep working. +const encPrefix = "enc:v1:" + +// IsEncrypted reports whether value carries the ciphertext marker. +func IsEncrypted(value string) bool { + return strings.HasPrefix(value, encPrefix) +} + +// deriveKey turns an arbitrary-length secret into a 32-byte AES-256 key. +func deriveKey(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +// EncryptWithKey returns an AES-256-GCM ciphertext (base64, prefixed) for value. +// Encryption is opt-in: an empty secret or empty value returns value unchanged, +// and an already-encrypted value is returned as-is so re-saving is idempotent. +func EncryptWithKey(secret, value string) (string, error) { + if secret == "" || value == "" || IsEncrypted(value) { + return value, nil + } + + gcm, err := newGCM(secret) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + // Prepend the nonce to the ciphertext so DecryptWithKey can recover it. + sealed := gcm.Seal(nonce, nonce, []byte(value), nil) + return encPrefix + base64.StdEncoding.EncodeToString(sealed), nil +} + +// DecryptWithKey reverses EncryptWithKey. A value without the marker is treated +// as legacy plaintext and returned unchanged, which keeps pre-encryption rows +// usable after the key is switched on. +func DecryptWithKey(secret, value string) (string, error) { + if !IsEncrypted(value) { + return value, nil + } + if secret == "" { + return "", errors.New("value is encrypted but no encryption key is configured") + } + + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, encPrefix)) + if err != nil { + return "", err + } + + gcm, err := newGCM(secret) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("invalid ciphertext: too short") + } + + nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func newGCM(secret string) (cipher.AEAD, error) { + block, err := aes.NewCipher(deriveKey(secret)) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/util/crypto_test.go b/util/crypto_test.go new file mode 100644 index 0000000..b07fe76 --- /dev/null +++ b/util/crypto_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import "testing" + +func TestEncryptDecryptRoundTrip(t *testing.T) { + secret := "test-secret" + plaintext := "sk-1234567890abcdef" + + ciphertext, err := EncryptWithKey(secret, plaintext) + if err != nil { + t.Fatalf("EncryptWithKey failed: %v", err) + } + if ciphertext == plaintext { + t.Fatal("ciphertext equals plaintext; value was not encrypted") + } + if !IsEncrypted(ciphertext) { + t.Fatalf("ciphertext is missing the encryption marker: %q", ciphertext) + } + + got, err := DecryptWithKey(secret, ciphertext) + if err != nil { + t.Fatalf("DecryptWithKey failed: %v", err) + } + if got != plaintext { + t.Fatalf("round trip mismatch: got %q, want %q", got, plaintext) + } +} + +func TestEncryptIsNonDeterministic(t *testing.T) { + secret, plaintext := "s", "same-value" + a, _ := EncryptWithKey(secret, plaintext) + b, _ := EncryptWithKey(secret, plaintext) + if a == b { + t.Fatal("two encryptions produced identical ciphertext; nonce is not random") + } +} + +func TestEncryptOptOut(t *testing.T) { + // No secret means encryption is disabled: the value is returned unchanged. + if got, _ := EncryptWithKey("", "plain"); got != "plain" { + t.Fatalf("empty secret should pass through, got %q", got) + } + // An empty value is never wrapped. + if got, _ := EncryptWithKey("secret", ""); got != "" { + t.Fatalf("empty value should pass through, got %q", got) + } +} + +func TestEncryptIdempotent(t *testing.T) { + secret := "s" + once, _ := EncryptWithKey(secret, "value") + twice, err := EncryptWithKey(secret, once) + if err != nil { + t.Fatalf("re-encrypt failed: %v", err) + } + if twice != once { + t.Fatal("re-encrypting an already-encrypted value changed it") + } +} + +func TestDecryptLegacyPlaintext(t *testing.T) { + // A value written before encryption was enabled has no marker and must be + // returned untouched, even without a key. + if got, err := DecryptWithKey("", "legacy-plain-key"); err != nil || got != "legacy-plain-key" { + t.Fatalf("legacy plaintext passthrough failed: got %q, err %v", got, err) + } + if got, err := DecryptWithKey("secret", "legacy-plain-key"); err != nil || got != "legacy-plain-key" { + t.Fatalf("legacy plaintext passthrough with key failed: got %q, err %v", got, err) + } +} + +func TestDecryptWrongKeyFails(t *testing.T) { + ciphertext, _ := EncryptWithKey("right-key", "secret-value") + if _, err := DecryptWithKey("wrong-key", ciphertext); err == nil { + t.Fatal("decrypting with the wrong key should fail") + } +} + +func TestDecryptEncryptedWithoutKeyFails(t *testing.T) { + ciphertext, _ := EncryptWithKey("key", "secret-value") + if _, err := DecryptWithKey("", ciphertext); err == nil { + t.Fatal("decrypting ciphertext without a configured key should fail") + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
