mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-13 01:25:11 +00:00
fix(oidc): use authenticated encryption for opaque tokens (#12017)
# Which Problems Are Solved Opaque tokens now use authenticated encryption. # How the Problems Are Solved - Upgrade zitadel/oidc to v3.47 - Copy crypto implementation for refresh and session tokens (internal to zitadel) - Added config that allows validating old tokens for gradual roll-out # Additional Changes - Set NX cache for `integration-test-build` to `false`, working on a seperate fix. # Additional Context - closes #11315 --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: muhlemmer <[email protected]>
This commit is contained in:
@@ -5,9 +5,12 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
@@ -17,6 +20,7 @@ type AESCrypto struct {
|
||||
keys map[string]string
|
||||
encryptionKeyID string
|
||||
keyIDs []string
|
||||
legacyToken bool
|
||||
}
|
||||
|
||||
func NewAESCrypto(config *KeyConfig, keyStorage KeyStorage) (*AESCrypto, error) {
|
||||
@@ -28,6 +32,7 @@ func NewAESCrypto(config *KeyConfig, keyStorage KeyStorage) (*AESCrypto, error)
|
||||
keys: keys,
|
||||
encryptionKeyID: config.EncryptionKeyID,
|
||||
keyIDs: ids,
|
||||
legacyToken: config.LegacyToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -69,6 +74,81 @@ func (a *AESCrypto) DecryptionKeyIDs() []string {
|
||||
return a.keyIDs
|
||||
}
|
||||
|
||||
func (a *AESCrypto) EncryptToken(data string) (string, error) {
|
||||
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
|
||||
Algorithm: jose.A256GCMKW,
|
||||
Key: a.encryptionKey(),
|
||||
KeyID: a.encryptionKeyID,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowInternal(err, "CRYPTO-Woox2", "Errors.Internal")
|
||||
}
|
||||
|
||||
encrypted, err := encrypter.Encrypt([]byte(data))
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowInternal(err, "CRYPTO-Woox3", "Errors.Internal")
|
||||
}
|
||||
|
||||
serialized, err := encrypted.CompactSerialize()
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowInternal(err, "CRYPT-Woox4", "Errors.Internal")
|
||||
}
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) DecryptToken(value string) (string, error) {
|
||||
key, err := a.decryptionKey(a.encryptionKeyID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err := a.decryptA256GCM(value, key)
|
||||
if err == nil {
|
||||
return decrypted, nil
|
||||
}
|
||||
if !a.legacyToken {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err2 := a.decryptLegacyToken(value, key)
|
||||
if err2 != nil {
|
||||
return "", errors.Join(err, err2)
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) LegacyTokenEnabled() bool {
|
||||
return a.legacyToken
|
||||
}
|
||||
|
||||
func (a *AESCrypto) decryptA256GCM(value string, key string) (string, error) {
|
||||
jwe, err := jose.ParseEncrypted(value, []jose.KeyAlgorithm{jose.A256GCMKW}, []jose.ContentEncryption{jose.A256GCM})
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowPreconditionFailed(err, "CRYPT-ha6Oh", "malformed encrypted value")
|
||||
}
|
||||
decrypted, err := jwe.Decrypt(key)
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowUnauthenticated(err, "CRYPT-OhN2u", "failed to decrypt value")
|
||||
}
|
||||
if !utf8.Valid(decrypted) {
|
||||
return "", zerrors.ThrowPreconditionFailed(err, "CRYPT-Koh1u", "non-UTF-8 in decrypted string")
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) decryptLegacyToken(value string, key string) (string, error) {
|
||||
text, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return "", zerrors.ThrowPreconditionFailed(err, "CRYPT-Eep6o", "malformed encrypted value")
|
||||
}
|
||||
decrypted, err := DecryptAES(text, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(decrypted) {
|
||||
return "", zerrors.ThrowPreconditionFailed(err, "CRYPT-ohGh6", "non-UTF-8 in decrypted string")
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
|
||||
func (a *AESCrypto) encryptionKey() string {
|
||||
return a.keys[a.encryptionKeyID]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -42,6 +43,18 @@ func newTestAESCrypto(t testing.TB) *AESCrypto {
|
||||
return aesCrypto
|
||||
}
|
||||
|
||||
func newTestAESCryptoLegacyToken(t testing.TB) *AESCrypto {
|
||||
keyConfig := &KeyConfig{
|
||||
LegacyToken: true,
|
||||
EncryptionKeyID: "keyID",
|
||||
DecryptionKeyIDs: []string{"keyID"},
|
||||
}
|
||||
keys := Keys{"keyID": "ThisKeyNeedsToHave32Characters!!"}
|
||||
aesCrypto, err := NewAESCrypto(keyConfig, &mockKeyStorage{keys: keys})
|
||||
require.NoError(t, err)
|
||||
return aesCrypto
|
||||
}
|
||||
|
||||
func TestAESCrypto_DecryptString(t *testing.T) {
|
||||
aesCrypto := newTestAESCrypto(t)
|
||||
const input = "SecretData"
|
||||
@@ -107,3 +120,122 @@ func FuzzAESCrypto_DecryptString(f *testing.F) {
|
||||
assert.True(t, utf8.ValidString(got), "result is not valid UTF-8")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAESCrypto_EncryptToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *KeyConfig
|
||||
keyStorage KeyStorage
|
||||
value string
|
||||
want bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "ok",
|
||||
config: &KeyConfig{
|
||||
EncryptionKeyID: "keyID",
|
||||
DecryptionKeyIDs: []string{"keyID"},
|
||||
},
|
||||
keyStorage: &mockKeyStorage{keys: Keys{"keyID": "ThisKeyNeedsToHave32Characters!!"}},
|
||||
value: "SecretData",
|
||||
want: true,
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "empty key error",
|
||||
config: &KeyConfig{
|
||||
EncryptionKeyID: "keyID",
|
||||
DecryptionKeyIDs: []string{"keyID"},
|
||||
},
|
||||
keyStorage: &mockKeyStorage{keys: Keys{"keyID": ""}},
|
||||
value: "SecretData",
|
||||
want: false,
|
||||
wantErr: zerrors.ThrowInternal(nil, "CRYPTO-Woox3", "Errors.Internal"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a, err := NewAESCrypto(tt.config, tt.keyStorage)
|
||||
require.NoError(t, err)
|
||||
got, gotErr := a.EncryptToken(tt.value)
|
||||
require.ErrorIs(t, gotErr, tt.wantErr)
|
||||
if tt.want {
|
||||
assert.NotEmpty(t, got, "expected non-empty result")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESCrypto_DecryptToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
crypto func(testing.TB) *AESCrypto
|
||||
payload func(*testing.T) string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "ok",
|
||||
crypto: newTestAESCrypto,
|
||||
payload: func(t *testing.T) string {
|
||||
a := newTestAESCrypto(t)
|
||||
crypted, err := a.EncryptToken("SecretData")
|
||||
require.NoError(t, err)
|
||||
return crypted
|
||||
},
|
||||
want: "SecretData",
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "malformed encrypted value",
|
||||
crypto: newTestAESCrypto,
|
||||
payload: func(t *testing.T) string {
|
||||
return "malformed"
|
||||
},
|
||||
wantErr: zerrors.ThrowPreconditionFailed(nil, "CRYPT-ha6Oh", "malformed encrypted value"),
|
||||
},
|
||||
{
|
||||
name: "invalid value",
|
||||
crypto: newTestAESCrypto,
|
||||
payload: func(t *testing.T) string {
|
||||
return "eyJhbGciOiJBMjU2R0NNS1ciLCJlbmMiOiJBMjU2R0NNIiwiaXYiOiJfcUNRelpoVDF4bjJfUjJiIiwia2lkIjoia2V5SUQiLCJ0YWciOiJlcm1EV1oySE5mOGctMHRJa29zdHpRIn0.zGWGOxwPI8iq-ZSwmAqc1Ps8tltCp-g815nj7jY_m9Q.U_tpQDuz8SzrDEAD._YzsLNPi2BrmWA.G0I-gIfkWy6DNhqXPD_EXg"
|
||||
},
|
||||
wantErr: zerrors.ThrowUnauthenticated(nil, "CRYPT-OhN2u", "failed to decrypt value"),
|
||||
},
|
||||
{
|
||||
name: "legacy decrypt",
|
||||
crypto: newTestAESCryptoLegacyToken,
|
||||
payload: func(t *testing.T) string {
|
||||
a := newTestAESCryptoLegacyToken(t)
|
||||
encrypted, err := a.Encrypt([]byte("SecretData"))
|
||||
require.NoError(t, err)
|
||||
return base64.RawURLEncoding.EncodeToString(encrypted)
|
||||
},
|
||||
want: "SecretData",
|
||||
},
|
||||
{
|
||||
name: "legacy invalid base64",
|
||||
crypto: newTestAESCryptoLegacyToken,
|
||||
payload: func(t *testing.T) string {
|
||||
return "invalid_base64"
|
||||
},
|
||||
wantErr: zerrors.ThrowPreconditionFailed(nil, "CRYPT-ha6Oh", "malformed encrypted value"),
|
||||
},
|
||||
{
|
||||
name: "legacy invalid token",
|
||||
crypto: newTestAESCryptoLegacyToken,
|
||||
payload: func(t *testing.T) string {
|
||||
return "DEADBEEFDEADBEEFDEADBEEF"
|
||||
},
|
||||
wantErr: zerrors.ThrowPreconditionFailed(nil, "CRYPT-ohGh6", "non-UTF-8 in decrypted string"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := tt.crypto(t)
|
||||
got, gotErr := a.DecryptToken(tt.payload(t))
|
||||
require.ErrorIs(t, gotErr, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ const (
|
||||
|
||||
//go:generate mockgen -typed -package crypto -destination ./crypto.mock.go . EncryptionAlgorithm
|
||||
|
||||
// EncryptionAlgorithm is an interface that defines the methods for encrypting and decrypting values,
|
||||
// used for sensitive data stored in the database.
|
||||
// These methods are not intended to be used for encrypting data that is sent to the client,
|
||||
// for this use the [AuthAlgorithm] instead.
|
||||
type EncryptionAlgorithm interface {
|
||||
Algorithm() string
|
||||
EncryptionKeyID() string
|
||||
@@ -28,6 +32,24 @@ type EncryptionAlgorithm interface {
|
||||
DecryptString(hashed []byte, keyID string) (string, error)
|
||||
}
|
||||
|
||||
// AuthAlgorithm is an interface that defines the methods for encrypting and decrypting tokens,
|
||||
// used for sensitive data sent to the client, such as access tokens and refresh tokens.
|
||||
// Implementation must obfuscate and authenticate the token.
|
||||
type AuthAlgorithm interface {
|
||||
// EncryptToken encrypts the token using authenticated encryption with the current encryption key.
|
||||
EncryptToken(data string) (string, error)
|
||||
|
||||
// DecryptToken authenticates and decrypts the token using the encryption key.
|
||||
// When the decrypted value contains non-UTF8 characters an error is returned.
|
||||
DecryptToken(token string) (string, error)
|
||||
LegacyTokenEnabled() bool
|
||||
}
|
||||
|
||||
type AuthEncryptionAlgorithm interface {
|
||||
EncryptionAlgorithm
|
||||
AuthAlgorithm
|
||||
}
|
||||
|
||||
// CryptoValue is a struct that can be used to store encrypted values in a database.
|
||||
// The struct is compatible with the [driver.Valuer] and database/sql.Scanner interfaces.
|
||||
type CryptoValue struct {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type KeyConfig struct {
|
||||
LegacyToken bool
|
||||
EncryptionKeyID string
|
||||
DecryptionKeyIDs []string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user