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]
