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]

Reply via email to