-
Notifications
You must be signed in to change notification settings - Fork 179
Add KeyManager for signing key lifecycle to authserver #3407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package keys | ||
|
|
||
| // Config holds configuration for creating a KeyProvider. | ||
| // The caller is responsible for populating this from their own config source | ||
| // (environment variables, YAML files, flags, etc.). | ||
| type Config struct { | ||
| // KeyDir is the directory containing PEM-encoded private key files. | ||
| // All key filenames are relative to this directory. | ||
| // | ||
| // In Kubernetes deployments, this is typically a mounted Secret volume: | ||
| // | ||
| // volumeMounts: | ||
| // - name: signing-keys | ||
| // mountPath: /etc/toolhive/keys | ||
| KeyDir string | ||
|
|
||
| // SigningKeyFile is the filename of the primary signing key (relative to KeyDir). | ||
| // This key is used for signing new tokens. | ||
| // If empty with KeyDir set, NewProviderFromConfig returns an error. | ||
| // If both KeyDir and SigningKeyFile are empty, an ephemeral key is generated. | ||
| SigningKeyFile string | ||
|
|
||
| // FallbackKeyFiles are filenames of additional keys for verification (relative to KeyDir). | ||
| // These keys are included in the JWKS endpoint for token verification but are NOT | ||
| // used for signing new tokens. | ||
| // | ||
| // Key rotation (single replica): update SigningKeyFile to the new key and move | ||
| // the old filename here. Tokens signed with old keys remain verifiable until | ||
| // they expire. | ||
| // | ||
| // Key rotation (multiple replicas): to avoid a window where one replica signs | ||
| // with a key not yet advertised by another replica's JWKS endpoint: | ||
| // 1. Add the new key to FallbackKeyFiles and roll out to all replicas. | ||
| // 2. Promote it to SigningKeyFile, move the old key to FallbackKeyFiles, roll out. | ||
| // 3. Remove the old key from FallbackKeyFiles after its tokens have expired. | ||
| FallbackKeyFiles []string | ||
| } | ||
|
|
||
| // NewProviderFromConfig creates a KeyProvider based on the configuration. | ||
| // | ||
| // Behavior: | ||
| // - If KeyDir and SigningKeyFile are set: load keys from directory | ||
| // - If both are empty: return GeneratingProvider (ephemeral key for development) | ||
| // - If KeyDir is set but SigningKeyFile is empty: returns an error | ||
| func NewProviderFromConfig(cfg Config) (KeyProvider, error) { | ||
| if cfg.KeyDir != "" { | ||
| return NewFileProvider(cfg) | ||
| } | ||
|
|
||
| // Generate ephemeral key (development only) | ||
| return NewGeneratingProvider(DefaultAlgorithm), nil | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package keys | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto" | ||
| "crypto/ecdsa" | ||
| "crypto/elliptic" | ||
| "crypto/rand" | ||
| "fmt" | ||
| "path/filepath" | ||
| "sync" | ||
| "time" | ||
|
|
||
| servercrypto "github.com/stacklok/toolhive/pkg/authserver/server/crypto" | ||
| "github.com/stacklok/toolhive/pkg/logger" | ||
| ) | ||
|
|
||
| //go:generate mockgen -destination=mocks/mock_provider.go -package=mocks -source=provider.go KeyProvider | ||
|
|
||
| // KeyProvider provides signing keys for JWT operations. | ||
| // Implementations handle key sourcing (file, memory, generation). | ||
| type KeyProvider interface { | ||
| // SigningKey returns the current signing key. | ||
| // Returns ErrNoSigningKey if no key is available. | ||
| SigningKey(ctx context.Context) (*SigningKeyData, error) | ||
|
|
||
| // PublicKeys returns all public keys for the JWKS endpoint. | ||
| // May return multiple keys during rotation periods. | ||
| PublicKeys(ctx context.Context) ([]*PublicKeyData, error) | ||
| } | ||
|
|
||
| // FileProvider loads signing keys from PEM files in a directory. | ||
| // The signing key is used for signing new tokens. | ||
| // All keys (signing + fallback) are exposed via PublicKeys() for JWKS. | ||
| // Keys are loaded once at construction time; changes require restart. | ||
| type FileProvider struct { | ||
| signingKey *SigningKeyData | ||
| allKeys []*SigningKeyData | ||
| } | ||
|
|
||
| // NewFileProvider creates a provider that loads keys from a directory. | ||
| // Config.SigningKeyFile is the primary key used for signing new tokens. | ||
| // Config.FallbackKeyFiles are loaded for JWKS verification (for key rotation). | ||
| // All keys are loaded immediately and validated. | ||
| // Supports RSA (PKCS1/PKCS8), ECDSA (SEC1/PKCS8), and Ed25519 keys. | ||
| func NewFileProvider(cfg Config) (*FileProvider, error) { | ||
| if cfg.SigningKeyFile == "" { | ||
| return nil, fmt.Errorf("signing key file is required") | ||
| } | ||
|
|
||
| // Load the primary signing key | ||
| signingKeyPath := filepath.Join(cfg.KeyDir, cfg.SigningKeyFile) | ||
| signingKey, err := loadKeyFromFile(signingKeyPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to load signing key: %w", err) | ||
| } | ||
|
|
||
| // Start with the signing key in allKeys | ||
| allKeys := []*SigningKeyData{signingKey} | ||
|
|
||
| // Load fallback keys for JWKS verification | ||
| for _, filename := range cfg.FallbackKeyFiles { | ||
| keyPath := filepath.Join(cfg.KeyDir, filename) | ||
| key, err := loadKeyFromFile(keyPath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to load fallback key %s: %w", filename, err) | ||
| } | ||
| allKeys = append(allKeys, key) | ||
| } | ||
|
|
||
| return &FileProvider{ | ||
| signingKey: signingKey, | ||
| allKeys: allKeys, | ||
| }, nil | ||
| } | ||
|
|
||
| // loadKeyFromFile loads a single key from a PEM file. | ||
| func loadKeyFromFile(keyPath string) (*SigningKeyData, error) { | ||
| signer, err := servercrypto.LoadSigningKey(keyPath) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| params, err := servercrypto.DeriveSigningKeyParams(signer, "", "") | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to derive key parameters: %w", err) | ||
| } | ||
|
|
||
| return &SigningKeyData{ | ||
| KeyID: params.KeyID, | ||
| Algorithm: params.Algorithm, | ||
| Key: params.Key, | ||
| CreatedAt: time.Now(), | ||
| }, nil | ||
| } | ||
|
|
||
| // SigningKey returns the primary signing key used for signing new tokens. | ||
| // Returns a copy to prevent external mutation of internal state. | ||
| func (p *FileProvider) SigningKey(_ context.Context) (*SigningKeyData, error) { | ||
| return &SigningKeyData{ | ||
| KeyID: p.signingKey.KeyID, | ||
| Algorithm: p.signingKey.Algorithm, | ||
| Key: p.signingKey.Key, | ||
| CreatedAt: p.signingKey.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| // PublicKeys returns public keys for all loaded keys (signing + additional). | ||
| // This enables verification of tokens signed with any of the loaded keys, | ||
| // supporting key rotation scenarios where old keys must remain valid. | ||
| func (p *FileProvider) PublicKeys(_ context.Context) ([]*PublicKeyData, error) { | ||
| pubKeys := make([]*PublicKeyData, 0, len(p.allKeys)) | ||
| for _, key := range p.allKeys { | ||
| pubKeys = append(pubKeys, &PublicKeyData{ | ||
| KeyID: key.KeyID, | ||
| Algorithm: key.Algorithm, | ||
| PublicKey: key.Key.Public(), | ||
| CreatedAt: key.CreatedAt, | ||
| }) | ||
| } | ||
| return pubKeys, nil | ||
| } | ||
|
|
||
| // GeneratingProvider generates an ephemeral key on first access. | ||
| // Suitable for development but NOT recommended for production. | ||
| // Generated keys are lost on restart, invalidating all issued tokens. | ||
| type GeneratingProvider struct { | ||
| algorithm string | ||
| mu sync.Mutex | ||
| key *SigningKeyData | ||
| } | ||
|
|
||
| // NewGeneratingProvider creates a provider that generates an ephemeral key. | ||
| // The key is generated lazily on first SigningKey() call. | ||
| // If algorithm is empty, DefaultAlgorithm (ES256) is used. | ||
| func NewGeneratingProvider(algorithm string) *GeneratingProvider { | ||
| if algorithm == "" { | ||
| algorithm = DefaultAlgorithm | ||
| } | ||
| return &GeneratingProvider{algorithm: algorithm} | ||
| } | ||
|
|
||
| // SigningKey returns the signing key, generating one if needed. | ||
| // Thread-safe: uses mutex to ensure only one key is generated. | ||
| // Returns a copy to prevent external mutation of internal state. | ||
| func (p *GeneratingProvider) SigningKey(_ context.Context) (*SigningKeyData, error) { | ||
| p.mu.Lock() | ||
| defer p.mu.Unlock() | ||
|
|
||
| if p.key != nil { | ||
| return &SigningKeyData{ | ||
| KeyID: p.key.KeyID, | ||
| Algorithm: p.key.Algorithm, | ||
| Key: p.key.Key, | ||
| CreatedAt: p.key.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| key, err := p.generateKey() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| logger.Warnw("generated ephemeral signing key - tokens will be invalid after restart", | ||
| "algorithm", key.Algorithm, | ||
| "keyID", key.KeyID, | ||
| ) | ||
|
|
||
| p.key = key | ||
| return &SigningKeyData{ | ||
jhrozek marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| KeyID: p.key.KeyID, | ||
| Algorithm: p.key.Algorithm, | ||
| Key: p.key.Key, | ||
| CreatedAt: p.key.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| // PublicKeys returns the public key for JWKS. | ||
| // Generates the signing key if it hasn't been generated yet. | ||
| func (p *GeneratingProvider) PublicKeys(ctx context.Context) ([]*PublicKeyData, error) { | ||
| key, err := p.SigningKey(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return []*PublicKeyData{{ | ||
| KeyID: key.KeyID, | ||
| Algorithm: key.Algorithm, | ||
| PublicKey: key.Key.Public(), | ||
| CreatedAt: key.CreatedAt, | ||
| }}, nil | ||
| } | ||
|
|
||
| func (p *GeneratingProvider) generateKey() (*SigningKeyData, error) { | ||
| privateKey, err := generatePrivateKey(p.algorithm) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to generate signing key: %w", err) | ||
| } | ||
|
|
||
| keyID, err := servercrypto.DeriveKeyID(privateKey) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to derive key ID: %w", err) | ||
| } | ||
|
|
||
| return &SigningKeyData{ | ||
| KeyID: keyID, | ||
| Algorithm: p.algorithm, | ||
| Key: privateKey, | ||
| CreatedAt: time.Now(), | ||
| }, nil | ||
| } | ||
|
|
||
| // generatePrivateKey creates a new private key for the specified algorithm. | ||
| func generatePrivateKey(algorithm string) (crypto.Signer, error) { | ||
| switch algorithm { | ||
| case "ES256": | ||
| return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| case "ES384": | ||
| return ecdsa.GenerateKey(elliptic.P384(), rand.Reader) | ||
| case "ES512": | ||
| return ecdsa.GenerateKey(elliptic.P521(), rand.Reader) | ||
| default: | ||
| return nil, fmt.Errorf("unsupported algorithm for key generation: %s", algorithm) | ||
| } | ||
| } | ||
|
|
||
| // Compile-time interface checks. | ||
| var ( | ||
| _ KeyProvider = (*FileProvider)(nil) | ||
| _ KeyProvider = (*GeneratingProvider)(nil) | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.