This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new ae69e8b feat(channel): encrypt API keys at rest with AES-256-GCM
(#211)
ae69e8b is described below
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]