From b558a1f79f3253fb82ef47e81e7d65b48c3a060a Mon Sep 17 00:00:00 2001 From: Bertrand <40759640+bfoing@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:30:23 +0100 Subject: [PATCH] feat: allow ECDSA and ED25519 public keys (#11819) # Which Problems Are Solved Currently ZITADEL only allows the use of RSA public keys for machine user authentication (jwt-bearer grant), including for system API users. Attempting to use ECDSA (e.g. P-256) or ED25519 keys results in `Errors.Internal` because `BytesToPublicKey` performs an `ifc.(*rsa.PublicKey)` type assertion that returns `(nil, nil)` for non-RSA keys, which then causes a nil key panic in go-jose during JWT verification. This is the same fix as #8433 (by @livio-a), rebased onto current `main`. # How the Problems Are Solved - `BytesToPublicKey` now returns `crypto.PublicKey` (the standard library interface) instead of `*rsa.PublicKey` - A type switch validates the parsed key is one of `*rsa.PublicKey`, `*ecdsa.PublicKey`, or `ed25519.PublicKey` - A new `ErrNoPublicKey` sentinel error is returned for unsupported key types instead of silently returning nil - Callers in `system_token.go` and `query/key.go` are updated to use the generic `crypto.PublicKey` interface # Additional Changes None # Additional Context Duplicate of #8433 which has been open since August 2024. We hit this bug while implementing OIDC bootstrap for an SGX enclave that generates ECDSA P-256 keys at runtime -- the `AddKey` API accepts the ECDSA SPKI PEM fine, but the subsequent `jwt-bearer` token exchange fails with `Errors.Internal` due to the nil key. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Wim Van Laer --- internal/api/authz/system_token.go | 7 +- internal/crypto/rsa.go | 18 +++-- internal/crypto/rsa_test.go | 111 +++++++++++++++++++++++++++++ internal/query/key.go | 10 +-- 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 internal/crypto/rsa_test.go diff --git a/internal/api/authz/system_token.go b/internal/api/authz/system_token.go index c7c2e49ec..d770ca784 100644 --- a/internal/api/authz/system_token.go +++ b/internal/api/authz/system_token.go @@ -2,6 +2,7 @@ package authz import ( "context" + "crypto" "crypto/rsa" "crypto/x509" "encoding/pem" @@ -13,7 +14,7 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/zitadel/oidc/v3/pkg/op" - "github.com/zitadel/zitadel/internal/crypto" + zcrypto "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -89,7 +90,7 @@ type SystemAPIUser struct { } type SystemAPIPublicKey struct { - Data *rsa.PublicKey + Data crypto.PublicKey NotBefore *time.Time NotAfter *time.Time } @@ -104,7 +105,7 @@ func (s *SystemAPIUser) readKey() (*SystemAPIPublicKey, error) { } // when an RSA key is provided, use the raw data - key, err := crypto.BytesToPublicKey(s.KeyData) + key, err := zcrypto.BytesToPublicKey(s.KeyData) if err == nil { return &SystemAPIPublicKey{Data: key}, nil } diff --git a/internal/crypto/rsa.go b/internal/crypto/rsa.go index bba27b70a..85d74edae 100644 --- a/internal/crypto/rsa.go +++ b/internal/crypto/rsa.go @@ -3,6 +3,8 @@ package crypto import ( "bytes" "crypto" + "crypto/ecdsa" + "crypto/ed25519" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -205,8 +207,9 @@ func BytesToPrivateKeyPKCS8[T crypto.PrivateKey](priv []byte) (T, error) { } var ErrEmpty = errors.New("cannot decode, empty data") +var ErrNoPublicKey = errors.New("unsupported public key type") -func BytesToPublicKey(pub []byte) (*rsa.PublicKey, error) { +func BytesToPublicKey(pub []byte) (crypto.PublicKey, error) { if len(pub) == 0 { return nil, ErrEmpty } @@ -214,15 +217,18 @@ func BytesToPublicKey(pub []byte) (*rsa.PublicKey, error) { if block == nil { return nil, ErrEmpty } - ifc, err := x509.ParsePKIXPublicKey(block.Bytes) + key, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, err } - key, ok := ifc.(*rsa.PublicKey) - if !ok { - return nil, err + switch key.(type) { + case *rsa.PublicKey, + *ecdsa.PublicKey, + ed25519.PublicKey: + return key, nil + default: + return nil, ErrNoPublicKey } - return key, nil } func EncryptKeys(privateKey *rsa.PrivateKey, publicKey *rsa.PublicKey, alg EncryptionAlgorithm) (*CryptoValue, *CryptoValue, error) { diff --git a/internal/crypto/rsa_test.go b/internal/crypto/rsa_test.go new file mode 100644 index 000000000..9e5efd4c6 --- /dev/null +++ b/internal/crypto/rsa_test.go @@ -0,0 +1,111 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "testing" +) + +func mustGenerateRSAPEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) +} + +func mustGenerateECDSAPEM(t *testing.T) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) +} + +func mustGenerateEd25519PEM(t *testing.T) []byte { + t.Helper() + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) +} + +func TestBytesToPublicKey_RSA(t *testing.T) { + pemData := mustGenerateRSAPEM(t) + key, err := BytesToPublicKey(pemData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := key.(*rsa.PublicKey); !ok { + t.Fatalf("expected *rsa.PublicKey, got %T", key) + } +} + +func TestBytesToPublicKey_ECDSA(t *testing.T) { + pemData := mustGenerateECDSAPEM(t) + key, err := BytesToPublicKey(pemData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := key.(*ecdsa.PublicKey); !ok { + t.Fatalf("expected *ecdsa.PublicKey, got %T", key) + } +} + +func TestBytesToPublicKey_Ed25519(t *testing.T) { + pemData := mustGenerateEd25519PEM(t) + key, err := BytesToPublicKey(pemData) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := key.(ed25519.PublicKey); !ok { + t.Fatalf("expected ed25519.PublicKey, got %T", key) + } +} + +func TestBytesToPublicKey_Empty(t *testing.T) { + _, err := BytesToPublicKey(nil) + if !errors.Is(err, ErrEmpty) { + t.Fatalf("expected ErrEmpty, got %v", err) + } + _, err = BytesToPublicKey([]byte{}) + if !errors.Is(err, ErrEmpty) { + t.Fatalf("expected ErrEmpty, got %v", err) + } +} + +func TestBytesToPublicKey_InvalidPEM(t *testing.T) { + _, err := BytesToPublicKey([]byte("not a PEM block")) + if !errors.Is(err, ErrEmpty) { + t.Fatalf("expected ErrEmpty for non-PEM data, got %v", err) + } +} + +func TestBytesToPublicKey_MalformedDER(t *testing.T) { + malformed := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("invalid DER data")}) + _, err := BytesToPublicKey(malformed) + if err == nil { + t.Fatal("expected parse error for malformed DER, got nil") + } +} diff --git a/internal/query/key.go b/internal/query/key.go index e7b81bb95..faf3eb079 100644 --- a/internal/query/key.go +++ b/internal/query/key.go @@ -3,21 +3,21 @@ package query import ( "time" - "github.com/zitadel/zitadel/internal/crypto" + zcrypto "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query/projection" ) type Key interface { ID() string Algorithm() string - Use() crypto.KeyUsage + Use() zcrypto.KeyUsage Sequence() uint64 } type PrivateKey interface { Key Expiry() time.Time - Key() *crypto.CryptoValue + Key() *zcrypto.CryptoValue } type PublicKey interface { @@ -38,7 +38,7 @@ type key struct { sequence uint64 resourceOwner string algorithm string - use crypto.KeyUsage + use zcrypto.KeyUsage } func (k *key) ID() string { @@ -49,7 +49,7 @@ func (k *key) Algorithm() string { return k.algorithm } -func (k *key) Use() crypto.KeyUsage { +func (k *key) Use() zcrypto.KeyUsage { return k.use }