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

commit 4cfbdeff9e1ea833822c0d7a9c4aff06e9584f70
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                 |   7 ++
 object/channel.go             |  97 ++++++++++++++++++-
 object/channel_crypto_test.go | 219 ++++++++++++++++++++++++++++++++++++++++++
 util/crypto.go                | 125 ++++++++++++++++++++++++
 util/crypto_test.go           | 173 +++++++++++++++++++++++++++++++++
 5 files changed, 619 insertions(+), 2 deletions(-)

diff --git a/conf/app.conf b/conf/app.conf
index e00c500..4e7c292 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -18,6 +18,13 @@ 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. Any length 
works
+; ("openssl rand -base64 32"); leave it empty to keep storing keys as 
plaintext.
+; Existing plaintext keys are re-written as ciphertext the first time they are
+; read, so turning this on needs no manual step. Back the key up with the
+; database: losing it 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..73862fd 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -21,8 +21,10 @@ import (
        "net/http"
        "net/url"
        "strings"
+       "sync"
        "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 +41,85 @@ var ErrNoChannelAvailable = errors.New("no available 
channel")
 // (including an empty string) overwrites the stored key.
 const ApiKeyMask = "***"
 
+// apiKeyEncryptionSecret is empty when encryption is off, which keeps keys
+// stored as plaintext like before.
+func apiKeyEncryptionSecret() string {
+       return conf.GetConfigString("apiKeyEncryptionKey")
+}
+
+// apiKeyAad binds the ciphertext to its own row, so a value copied into 
another
+// channel's api_key column no longer decrypts.
+func apiKeyAad(channel *Channel) string {
+       return channel.GetId()
+}
+
+// encryptApiKey needs channel.Owner and channel.Name to be set already.
+func encryptApiKey(channel *Channel) error {
+       encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), 
channel.ApiKey, apiKeyAad(channel))
+       if err != nil {
+               return err
+       }
+       channel.ApiKey = encrypted
+       return nil
+}
+
+// decryptChannel restores the plaintext ApiKey on a channel just read from the
+// database. A failure leaves the stored value in place rather than dropping 
the
+// channel, but is logged: otherwise a changed key looks exactly like a healthy
+// channel whose upstream answers 401.
+func decryptChannel(channel *Channel) {
+       if channel == nil {
+               return
+       }
+
+       secret := apiKeyEncryptionSecret()
+       stored := channel.ApiKey
+
+       plain, err := util.DecryptWithKey(secret, stored, apiKeyAad(channel))
+       if err != nil {
+               fmt.Printf("decryptChannel(): channel [%s]: %v\n", 
channel.GetId(), err)
+               return
+       }
+       channel.ApiKey = plain
+
+       if util.NeedsReEncryption(secret, stored) {
+               upgradeStoredApiKey(channel)
+       }
+}
+
+// apiKeyUpgrades collapses concurrent upgrades of the same row: 
GetChannelsByModel()
+// runs on every proxied request.
+var apiKeyUpgrades sync.Map
+
+// upgradeStoredApiKey rewrites a plaintext or older-format key in the current
+// format. Only api_key is touched, so UpdatedTime keeps reflecting the last 
real
+// edit. A failure is logged and ignored, and retried on the next read.
+func upgradeStoredApiKey(channel *Channel) {
+       id := channel.GetId()
+       if _, busy := apiKeyUpgrades.LoadOrStore(id, struct{}{}); busy {
+               return
+       }
+       defer apiKeyUpgrades.Delete(id)
+
+       encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(), 
channel.ApiKey, apiKeyAad(channel))
+       if err != nil {
+               fmt.Printf("upgradeStoredApiKey(): channel [%s]: %v\n", id, err)
+               return
+       }
+
+       _, err = ormer.Engine.ID(core.PK{channel.Owner, channel.Name}).
+               Cols("api_key").Update(&Channel{ApiKey: encrypted})
+       if err != nil {
+               fmt.Printf("upgradeStoredApiKey(): channel [%s]: %v\n", id, err)
+       }
+}
+
+func decryptChannels(channels []*Channel) {
+       for _, channel := range channels {
+               decryptChannel(channel)
+       }
+}
+
 const (
        maxChannelModels     = 200
        maxChannelModelChars = 100
@@ -76,8 +157,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 string `xorm:"varchar(500)" json:"apiKey"`
+       // ApiKey holds base64 ciphertext, not the bare key, when
+       // "apiKeyEncryptionKey" is set in app.conf, hence the wider column.
+       ApiKey string `xorm:"varchar(1000)" 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.
        Models []string `xorm:"mediumtext" json:"models"`
@@ -94,6 +176,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 +189,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 +202,7 @@ func getChannel(owner, name string) (*Channel, error) {
        if !existed {
                return nil, nil
        }
+       decryptChannel(channel)
        return channel, nil
 }
 
@@ -231,6 +316,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 +346,8 @@ 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 {
+               return false, err
        }
 
        affected, err := session.AllCols().Update(channel)
@@ -351,6 +442,8 @@ func GetChannelsByModel(model string) ([]*Channel, error) {
                }
        }
 
+       decryptChannels(matchedChannels)
+
        if len(matchedChannels) == 0 {
                return nil, fmt.Errorf("%w: %s", ErrNoChannelAvailable, model)
        }
diff --git a/object/channel_crypto_test.go b/object/channel_crypto_test.go
new file mode 100644
index 0000000..828a0ab
--- /dev/null
+++ b/object/channel_crypto_test.go
@@ -0,0 +1,219 @@
+// 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 object
+
+import (
+       "testing"
+
+       "github.com/apache/casbin-gateway/util"
+       "github.com/xorm-io/core"
+)
+
+func readStoredApiKey(t *testing.T, owner, name string) string {
+       t.Helper()
+
+       results, err := ormer.Engine.Table("channel").
+               Where("owner = ? and name = ?", owner, name).QueryString()
+       if err != nil {
+               t.Fatal(err)
+       }
+       if len(results) != 1 {
+               t.Fatalf("expected 1 stored channel, got %d", len(results))
+       }
+       return results[0]["api_key"]
+}
+
+func newTestChannel(name, apiKey string) *Channel {
+       return &Channel{
+               Owner:  "admin",
+               Name:   name,
+               Type:   "openai",
+               Status: "enabled",
+               Models: []string{"gpt-4o"},
+               ApiKey: apiKey,
+       }
+}
+
+func TestChannelApiKeyIsEncryptedAtRest(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": 
"test-encryption-key"})
+
+       const plainKey = "sk-1234567890abcdef"
+       if _, err := AddChannel(newTestChannel("encrypted", plainKey)); err != 
nil {
+               t.Fatal(err)
+       }
+
+       stored := readStoredApiKey(t, "admin", "encrypted")
+       if stored == plainKey {
+               t.Fatal("the api key was written to the database as plaintext")
+       }
+       if !util.IsEncrypted(stored) {
+               t.Fatalf("the stored api key is missing the encryption marker: 
%q", stored)
+       }
+
+       channel, err := GetChannel("admin/encrypted")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if channel.ApiKey != plainKey {
+               t.Fatalf("reading back the channel gave %q, want %q", 
channel.ApiKey, plainKey)
+       }
+}
+
+func TestChannelApiKeyStaysPlaintextWithoutKey(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
+
+       const plainKey = "sk-not-encrypted"
+       if _, err := AddChannel(newTestChannel("plain", plainKey)); err != nil {
+               t.Fatal(err)
+       }
+
+       if stored := readStoredApiKey(t, "admin", "plain"); stored != plainKey {
+               t.Fatalf("with encryption off the key should be stored as-is, 
got %q", stored)
+       }
+}
+
+func TestLegacyPlaintextIsUpgradedOnRead(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
+
+       const plainKey = "sk-legacy-plaintext"
+       if _, err := AddChannel(newTestChannel("legacy", plainKey)); err != nil 
{
+               t.Fatal(err)
+       }
+       if stored := readStoredApiKey(t, "admin", "legacy"); stored != plainKey 
{
+               t.Fatalf("setup failed, the key should still be plaintext, got 
%q", stored)
+       }
+
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": 
"test-encryption-key"})
+
+       channel, err := GetChannel("admin/legacy")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if channel.ApiKey != plainKey {
+               t.Fatalf("a legacy plaintext key should still be readable, got 
%q", channel.ApiKey)
+       }
+
+       stored := readStoredApiKey(t, "admin", "legacy")
+       if !util.IsEncrypted(stored) {
+               t.Fatalf("the legacy key should have been re-written as 
ciphertext, got %q", stored)
+       }
+
+       channel, err = GetChannel("admin/legacy")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if channel.ApiKey != plainKey {
+               t.Fatalf("after the upgrade the key read back as %q, want %q", 
channel.ApiKey, plainKey)
+       }
+}
+
+func TestUpgradeOnlyTouchesTheApiKeyColumn(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": ""})
+
+       if _, err := AddChannel(newTestChannel("untouched", "sk-legacy")); err 
!= nil {
+               t.Fatal(err)
+       }
+       before, err := GetChannel("admin/untouched")
+       if err != nil {
+               t.Fatal(err)
+       }
+
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": 
"test-encryption-key"})
+       if _, err = GetChannel("admin/untouched"); err != nil {
+               t.Fatal(err)
+       }
+
+       after, err := GetChannel("admin/untouched")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if after.UpdatedTime != before.UpdatedTime {
+               t.Fatalf("the upgrade changed UpdatedTime: %q -> %q", 
before.UpdatedTime, after.UpdatedTime)
+       }
+       if len(after.Models) != 1 || after.Models[0] != "gpt-4o" {
+               t.Fatalf("the upgrade damaged the models column: %v", 
after.Models)
+       }
+       if after.Status != before.Status || after.Type != before.Type {
+               t.Fatal("the upgrade changed columns other than api_key")
+       }
+}
+
+func TestCiphertextIsBoundToItsChannel(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": 
"test-encryption-key"})
+
+       if _, err := AddChannel(newTestChannel("victim", "sk-victims-key")); 
err != nil {
+               t.Fatal(err)
+       }
+       if _, err := AddChannel(newTestChannel("thief", "sk-thiefs-key")); err 
!= nil {
+               t.Fatal(err)
+       }
+
+       victimCipher := readStoredApiKey(t, "admin", "victim")
+       if _, err := ormer.Engine.ID(core.PK{"admin", "thief"}).
+               Cols("api_key").Update(&Channel{ApiKey: victimCipher}); err != 
nil {
+               t.Fatal(err)
+       }
+
+       thief, err := GetChannel("admin/thief")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if thief.ApiKey == "sk-victims-key" {
+               t.Fatal("a ciphertext moved to another channel decrypted, it is 
not bound to its row")
+       }
+       if !util.IsEncrypted(thief.ApiKey) {
+               t.Fatalf("a value that cannot be decrypted should be left 
alone, got %q", thief.ApiKey)
+       }
+
+       victim, err := GetChannel("admin/victim")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if victim.ApiKey != "sk-victims-key" {
+               t.Fatalf("the victim channel read back as %q", victim.ApiKey)
+       }
+}
+
+func TestUpdateChannelKeepsMaskedApiKey(t *testing.T) {
+       initSqliteOrmer(t)
+       setEnv(t, map[string]string{"apiKeyEncryptionKey": 
"test-encryption-key"})
+
+       const plainKey = "sk-keep-me"
+       if _, err := AddChannel(newTestChannel("masked", plainKey)); err != nil 
{
+               t.Fatal(err)
+       }
+
+       edited := newTestChannel("masked", ApiKeyMask)
+       edited.DisplayName = "renamed"
+       if _, err := UpdateChannel("admin/masked", edited); err != nil {
+               t.Fatal(err)
+       }
+
+       channel, err := GetChannel("admin/masked")
+       if err != nil {
+               t.Fatal(err)
+       }
+       if channel.ApiKey != plainKey {
+               t.Fatalf("the stored key changed on a masked update: got %q, 
want %q", channel.ApiKey, plainKey)
+       }
+       if channel.DisplayName != "renamed" {
+               t.Fatalf("the update did not apply: displayName is %q", 
channel.DisplayName)
+       }
+}
diff --git a/util/crypto.go b/util/crypto.go
new file mode 100644
index 0000000..00fefef
--- /dev/null
+++ b/util/crypto.go
@@ -0,0 +1,125 @@
+// 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"
+       "fmt"
+       "io"
+       "strings"
+)
+
+// The prefix tells ciphertext apart from a legacy plaintext value, and carries
+// a format version: v1 is not bound to anything, v2 feeds the caller's aad to
+// GCM. v1 is accepted on read, never written any more.
+const (
+       encPrefixV1 = "enc:v1:"
+       encPrefix   = "enc:v2:"
+)
+
+// IsEncrypted reports whether value carries a ciphertext marker of any 
version.
+func IsEncrypted(value string) bool {
+       return strings.HasPrefix(value, encPrefix) || strings.HasPrefix(value, 
encPrefixV1)
+}
+
+// NeedsReEncryption reports whether a stored value should be written back: it
+// is either legacy plaintext or ciphertext from an older format version.
+func NeedsReEncryption(secret, value string) bool {
+       return secret != "" && value != "" && !strings.HasPrefix(value, 
encPrefix)
+}
+
+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.
+//
+// aad is authenticated but not encrypted, and DecryptWithKey has to be given 
the
+// same value. Pass whatever identifies the place the ciphertext is stored, so
+// that moving it elsewhere makes it undecryptable.
+func EncryptWithKey(secret, value, aad 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), []byte(aad))
+       return encPrefix + base64.StdEncoding.EncodeToString(sealed), nil
+}
+
+// DecryptWithKey reverses EncryptWithKey. A value without a 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, aad string) (string, error) {
+       if !IsEncrypted(value) {
+               return value, nil
+       }
+       if secret == "" {
+               return "", errors.New("the value is encrypted but no encryption 
key is configured")
+       }
+
+       prefix := encPrefix
+       if strings.HasPrefix(value, encPrefixV1) {
+               prefix, aad = encPrefixV1, ""
+       }
+
+       raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, 
prefix))
+       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, []byte(aad))
+       if err != nil {
+               // gcm.Open() gives the same answer for a wrong key and for a 
value moved
+               // to another row.
+               return "", fmt.Errorf("cannot decrypt the value, the encryption 
key may have changed: %w", 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..fe63a3c
--- /dev/null
+++ b/util/crypto_test.go
@@ -0,0 +1,173 @@
+// 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/rand"
+       "encoding/base64"
+       "io"
+       "strings"
+       "testing"
+)
+
+const testAad = "admin/channel1"
+
+func TestEncryptDecryptRoundTrip(t *testing.T) {
+       secret := "test-secret"
+       plaintext := "sk-1234567890abcdef"
+
+       ciphertext, err := EncryptWithKey(secret, plaintext, testAad)
+       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, testAad)
+       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, testAad)
+       b, _ := EncryptWithKey(secret, plaintext, testAad)
+       if a == b {
+               t.Fatal("two encryptions produced identical ciphertext; nonce 
is not random")
+       }
+}
+
+func TestEncryptOptOut(t *testing.T) {
+       if got, _ := EncryptWithKey("", "plain", testAad); got != "plain" {
+               t.Fatalf("empty secret should pass through, got %q", got)
+       }
+       if got, _ := EncryptWithKey("secret", "", testAad); got != "" {
+               t.Fatalf("empty value should pass through, got %q", got)
+       }
+}
+
+func TestEncryptIdempotent(t *testing.T) {
+       secret := "s"
+       once, _ := EncryptWithKey(secret, "value", testAad)
+       twice, err := EncryptWithKey(secret, once, testAad)
+       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) {
+       if got, err := DecryptWithKey("", "legacy-plain-key", testAad); 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", testAad); 
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", testAad)
+       if _, err := DecryptWithKey("wrong-key", ciphertext, testAad); err == 
nil {
+               t.Fatal("decrypting with the wrong key should fail")
+       }
+}
+
+func TestDecryptEncryptedWithoutKeyFails(t *testing.T) {
+       ciphertext, _ := EncryptWithKey("key", "secret-value", testAad)
+       if _, err := DecryptWithKey("", ciphertext, testAad); err == nil {
+               t.Fatal("decrypting ciphertext without a configured key should 
fail")
+       }
+}
+
+func TestDecryptWrongAadFails(t *testing.T) {
+       ciphertext, _ := EncryptWithKey("key", "secret-value", "admin/channel1")
+       if _, err := DecryptWithKey("key", ciphertext, "admin/channel2"); err 
== nil {
+               t.Fatal("decrypting with a different aad should fail")
+       }
+       if got, err := DecryptWithKey("key", ciphertext, "admin/channel1"); err 
!= nil || got != "secret-value" {
+               t.Fatalf("decrypting with the original aad failed: got %q, err 
%v", got, err)
+       }
+}
+
+func TestDecryptV1WithoutAad(t *testing.T) {
+       secret, plaintext := "key", "secret-value"
+
+       gcm, err := newGCM(secret)
+       if err != nil {
+               t.Fatalf("newGCM failed: %v", err)
+       }
+       nonce := make([]byte, gcm.NonceSize())
+       if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
+               t.Fatalf("nonce failed: %v", err)
+       }
+       sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+       v1 := encPrefixV1 + base64.StdEncoding.EncodeToString(sealed)
+
+       if !IsEncrypted(v1) {
+               t.Fatal("a v1 value should still be recognized as encrypted")
+       }
+       got, err := DecryptWithKey(secret, v1, testAad)
+       if err != nil {
+               t.Fatalf("decrypting a v1 value failed: %v", err)
+       }
+       if got != plaintext {
+               t.Fatalf("v1 round trip mismatch: got %q, want %q", got, 
plaintext)
+       }
+}
+
+func TestNeedsReEncryption(t *testing.T) {
+       v2, _ := EncryptWithKey("key", "value", testAad)
+
+       cases := []struct {
+               name   string
+               secret string
+               value  string
+               want   bool
+       }{
+               {"encryption off", "", "plaintext", false},
+               {"empty value", "key", "", false},
+               {"legacy plaintext", "key", "plaintext", true},
+               {"v1 ciphertext", "key", encPrefixV1 + "whatever", true},
+               {"current format", "key", v2, false},
+       }
+       for _, tc := range cases {
+               if got := NeedsReEncryption(tc.secret, tc.value); got != 
tc.want {
+                       t.Errorf("%s: NeedsReEncryption() = %v, want %v", 
tc.name, got, tc.want)
+               }
+       }
+}
+
+// The api_key column in object.Channel is sized against this growth.
+func TestCiphertextFitsColumn(t *testing.T) {
+       const maxKeyChars, columnWidth = 500, 1000
+
+       ciphertext, err := EncryptWithKey("key", strings.Repeat("a", 
maxKeyChars), testAad)
+       if err != nil {
+               t.Fatalf("EncryptWithKey failed: %v", err)
+       }
+       if len(ciphertext) > columnWidth {
+               t.Fatalf("a %d character key encrypts to %d characters, which 
overflows the varchar(%d) api_key column", maxKeyChars, len(ciphertext), 
columnWidth)
+       }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to