Files
zitadel/internal/api/authz/system_token.go
b558a1f79f 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 <wim+github@zitadel.com>
2026-04-09 11:30:23 +02:00

181 lines
5.3 KiB
Go

package authz
import (
"context"
"crypto"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"sync"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/oidc/v3/pkg/op"
zcrypto "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/zerrors"
)
var _ SystemTokenVerifier = (*SystemTokenVerifierFromConfig)(nil)
type SystemTokenVerifierFromConfig struct {
systemJWTProfile *op.JWTProfileVerifier
systemUsers map[string]Memberships
}
func StartSystemTokenVerifierFromConfig(issuer string, keys map[string]*SystemAPIUser) (*SystemTokenVerifierFromConfig, error) {
systemUsers := make(map[string]Memberships, len(keys))
for userID, key := range keys {
if len(key.Memberships) == 0 {
systemUsers[userID] = Memberships{{MemberType: MemberTypeSystem, Roles: []string{"SYSTEM_OWNER"}}}
continue
}
for _, membership := range key.Memberships {
switch membership.MemberType {
case MemberTypeSystem, MemberTypeIAM, MemberTypeOrganization:
systemUsers[userID] = key.Memberships
case MemberTypeUnspecified, MemberTypeProject, MemberTypeProjectGrant:
return nil, errors.New("for system users, only the membership types System, IAM and Organization are supported")
default:
return nil, errors.New("unknown membership type")
}
}
}
return &SystemTokenVerifierFromConfig{
systemJWTProfile: op.NewJWTProfileVerifier(
&systemJWTStorage{
keys: keys,
cachedKeys: make(map[string]*SystemAPIPublicKey),
},
issuer,
1*time.Hour,
time.Second,
),
systemUsers: systemUsers,
}, nil
}
func (s *SystemTokenVerifierFromConfig) VerifySystemToken(ctx context.Context, token string, orgID string) (matchingMemberships Memberships, userID string, err error) {
jwtReq, err := op.VerifyJWTAssertion(ctx, token, s.systemJWTProfile)
if err != nil {
return nil, "", err
}
systemUserMemberships, ok := s.systemUsers[jwtReq.Subject]
if !ok {
return nil, "", zerrors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
}
matchingMemberships = make(Memberships, 0, len(systemUserMemberships))
for _, membership := range systemUserMemberships {
if membership.MemberType == MemberTypeSystem ||
membership.MemberType == MemberTypeIAM && GetInstance(ctx).InstanceID() == membership.AggregateID ||
membership.MemberType == MemberTypeOrganization && orgID == membership.AggregateID {
matchingMemberships = append(matchingMemberships, membership)
}
}
return matchingMemberships, jwtReq.Subject, nil
}
type systemJWTStorage struct {
keys map[string]*SystemAPIUser
mutex sync.RWMutex
cachedKeys map[string]*SystemAPIPublicKey
}
type SystemAPIUser struct {
Path string // if a path is specified, the key/cert will be read from that path
KeyData []byte // else you can also specify the data directly in the KeyData
Memberships Memberships
}
type SystemAPIPublicKey struct {
Data crypto.PublicKey
NotBefore *time.Time
NotAfter *time.Time
}
func (s *SystemAPIUser) readKey() (*SystemAPIPublicKey, error) {
if s.Path != "" {
var err error
s.KeyData, err = os.ReadFile(s.Path)
if err != nil {
return nil, zerrors.ThrowInternal(err, "AUTHZ-JK31F", "Errors.NotFound")
}
}
// when an RSA key is provided, use the raw data
key, err := zcrypto.BytesToPublicKey(s.KeyData)
if err == nil {
return &SystemAPIPublicKey{Data: key}, nil
}
// when x.509 cert is provided, parse it and extract RSA key
block, _ := pem.Decode(s.KeyData)
if block == nil {
return nil, zerrors.ThrowInternal(err, "AUTHZ-FC8ohc", "Errors.SystemApiUser.CertDecodeFailed")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, zerrors.ThrowInternal(err, "AUTHZ-64nMHP", "Errors.SystemApiUser.CertParseFailed")
}
key, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, zerrors.ThrowInternal(err, "AUTHZ-PNKOMf", "Errors.SystemApiUser.UnsupportedPublicKey")
}
return &SystemAPIPublicKey{
Data: key,
NotBefore: &cert.NotBefore,
NotAfter: &cert.NotAfter,
}, nil
}
func (s *systemJWTStorage) GetKeyByIDAndClientID(_ context.Context, _, userID string) (*jose.JSONWebKey, error) {
now := time.Now().UTC()
s.mutex.RLock()
key, ok := s.cachedKeys[userID]
// If a key is found but expired, read delete it and mark it as not found. This will trigger the key to be read from
// file again in case the file was replaced with a new key.
if ok && key.NotAfter != nil && now.After(*key.NotAfter) {
delete(s.cachedKeys, userID)
key = nil
ok = false
}
s.mutex.RUnlock()
var err error
if !ok {
if key, err = s.readKey(userID); err != nil {
return nil, err
}
}
if key.NotBefore != nil && now.Before(*key.NotBefore) {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-NiJstf", "Errors.User.NotBefore")
}
if key.NotAfter != nil && now.After(*key.NotAfter) {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-CGmV4b", "Errors.User.NotAfter")
}
return &jose.JSONWebKey{KeyID: userID, Key: key.Data}, nil
}
func (s *systemJWTStorage) readKey(userID string) (*SystemAPIPublicKey, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
user, ok := s.keys[userID]
if !ok {
return nil, zerrors.ThrowNotFound(nil, "AUTHZ-asfd3", "Errors.User.NotFound")
}
key, err := user.readKey()
if err != nil {
return nil, err
}
s.cachedKeys[userID] = key
return key, err
}