diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index 12e09f25f..f697a3fe6 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -42,7 +42,7 @@ jobs: - name: Test run: | make web-build - go test -tags=${{ inputs.tags }} -timeout 15m -coverpkg=./... -coverprofile=${{ inputs.coverprofile }} -covermode=count ./... + go test -tags=${{ inputs.tags }} -timeout 5m -coverpkg=./... -coverprofile=${{ inputs.coverprofile }} -covermode=count ./... - name: Get total code coverage if: github.event_name == 'pull_request' id: cc @@ -67,7 +67,7 @@ jobs: git reset --hard ${{ github.event.pull_request.base.sha }} make web-build go generate ./... - go test -tags=${{ inputs.tags }} -timeout 15m -coverpkg=./... -coverprofile=base_coverage.out -covermode=count ./... + go test -tags=${{ inputs.tags }} -timeout 5m -coverpkg=./... -coverprofile=base_coverage.out -covermode=count ./... go tool cover -func=base_coverage.out > unit-base.txt git reset --hard $HEAD - name: Get base code coverage value diff --git a/cmd/generate_keygen.go b/cmd/generate_keygen.go index 8f0f03d4c..f2c4832b5 100644 --- a/cmd/generate_keygen.go +++ b/cmd/generate_keygen.go @@ -39,7 +39,7 @@ func keygenMain(cmd *cobra.Command, args []string) error { return errors.Wrap(err, "failed to get the current working directory") } if privateKeyPath == "" { - privateKeyPath = filepath.Join(wd, "issuer.jwk") + privateKeyPath = filepath.Join(wd, "issuer-keys") } else { privateKeyPath = filepath.Clean(strings.TrimSpace(privateKeyPath)) } @@ -68,9 +68,9 @@ func keygenMain(cmd *cobra.Command, args []string) error { return fmt.Errorf("file exists for public key under %s", publicKeyPath) } - viper.Set(param.IssuerKey.GetName(), privateKeyPath) + viper.Set(param.IssuerKeysDirectory.GetName(), privateKeyPath) - // GetIssuerPublicJWKS will generate the private key at IssuerKey if it does not exist + // GetIssuerPublicJWKS will generate the private key in IssuerKeysDirectory if it does not exist // and parse the private key and generate the corresponding public key for us pubkey, err := config.GetIssuerPublicJWKS() if err != nil { diff --git a/cmd/generate_keygen_test.go b/cmd/generate_keygen_test.go index 3eb111509..36abd4427 100644 --- a/cmd/generate_keygen_test.go +++ b/cmd/generate_keygen_test.go @@ -50,8 +50,8 @@ func setupTestRun(t *testing.T) string { return tmpDir } -func checkKeys(t *testing.T, privateKey, publicKey string) { - _, err := config.LoadPrivateKey(privateKey, false) +func checkKeys(t *testing.T, publicKey string) { + _, err := config.GetIssuerPrivateJWK() require.NoError(t, err) jwks, err := jwk.ReadFile(publicKey) @@ -81,7 +81,6 @@ func TestKeygenMain(t *testing.T) { checkKeys( t, - filepath.Join(tempDir, "issuer.jwk"), filepath.Join(tempDir, "issuer-pub.jwks"), ) }) @@ -97,7 +96,6 @@ func TestKeygenMain(t *testing.T) { checkKeys( t, - privateKeyPath, filepath.Join(tempWd, "issuer-pub.jwks"), ) }) @@ -113,7 +111,6 @@ func TestKeygenMain(t *testing.T) { checkKeys( t, - filepath.Join(tempWd, "issuer.jwk"), publicKeyPath, ) }) @@ -130,7 +127,6 @@ func TestKeygenMain(t *testing.T) { checkKeys( t, - privateKeyPath, filepath.Join(tempWd, "issuer-pub.jwks"), ) }) @@ -147,7 +143,6 @@ func TestKeygenMain(t *testing.T) { checkKeys( t, - filepath.Join(tempWd, "issuer.jwk"), publicKeyPath, ) }) diff --git a/cmd/registry_client.go b/cmd/registry_client.go index 8bc03d910..710254eef 100644 --- a/cmd/registry_client.go +++ b/cmd/registry_client.go @@ -32,14 +32,12 @@ import ( "net/url" "os" - "github.com/lestrrat-go/jwx/v2/jwk" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/pelicanplatform/pelican/config" - "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/registry" ) @@ -109,16 +107,11 @@ func registerANamespace(cmd *cobra.Command, args []string) { os.Exit(1) } - privateKeyRaw, err := config.LoadPrivateKey(param.IssuerKey.GetString(), false) + privateKey, err := config.GetIssuerPrivateJWK() if err != nil { log.Error("Failed to load private key", err) os.Exit(1) } - privateKey, err := jwk.FromRaw(privateKeyRaw) - if err != nil { - log.Error("Failed to create JWK private key", err) - os.Exit(1) - } if withIdentity { // We haven't added support to pass sitename from CLI, so leave it empty diff --git a/config/config.go b/config/config.go index d2aa66a26..89220b0b5 100644 --- a/config/config.go +++ b/config/config.go @@ -1027,6 +1027,7 @@ func SetServerDefaults(v *viper.Viper) error { v.SetDefault(param.Xrootd_Authfile.GetName(), filepath.Join(configDir, "xrootd", "authfile")) v.SetDefault(param.Xrootd_MacaroonsKeyFile.GetName(), filepath.Join(configDir, "macaroons-secret")) v.SetDefault(param.IssuerKey.GetName(), filepath.Join(configDir, "issuer.jwk")) + v.SetDefault(param.IssuerKeysDirectory.GetName(), filepath.Join(configDir, "issuer-keys")) v.SetDefault(param.Server_UIPasswordFile.GetName(), filepath.Join(configDir, "server-web-passwd")) v.SetDefault(param.Server_UIActivationCodeFile.GetName(), filepath.Join(configDir, "server-web-activation-code")) v.SetDefault(param.OIDC_ClientIDFile.GetName(), filepath.Join(configDir, "oidc-client-id")) @@ -1433,7 +1434,7 @@ func InitServer(ctx context.Context, currentServers server_structs.ServerType) e // As necessary, generate private keys, JWKS and corresponding certs - // Note: This function will generate a private key in the location stored by the viper var "IssuerKey" + // Note: This function will generate a private key in the location stored by the viper var "IssuerKeysDirectory" // iff there isn't any valid private key present in that location _, err = GetIssuerPublicJWKS() if err != nil { @@ -1484,6 +1485,8 @@ func SetClientDefaults(v *viper.Viper) error { configDir := v.GetString("ConfigDir") v.SetDefault(param.IssuerKey.GetName(), filepath.Join(configDir, "issuer.jwk")) + v.SetDefault(param.IssuerKeysDirectory.GetName(), filepath.Join(configDir, "issuer-keys")) + upperPrefix := GetPreferredPrefix() if upperPrefix == OsdfPrefix || upperPrefix == StashPrefix { v.SetDefault("Federation.TopologyNamespaceURL", "https://topology.opensciencegrid.org/osdf/namespaces") @@ -1638,6 +1641,9 @@ func ResetConfig() { globalFedErr = nil ResetIssuerJWKPtr() + ResetIssuerPrivateKeys() + ResetPreviousIssuerPrivateJWK() + ResetClientInitialized() // other than what's above, resetting Origin exports will be done by ResetTestState() in server_utils pkg diff --git a/config/encrypted.go b/config/encrypted.go index 189edde28..155eb3786 100644 --- a/config/encrypted.go +++ b/config/encrypted.go @@ -22,7 +22,6 @@ import ( "crypto/aes" "crypto/cipher" "crypto/ed25519" - "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/sha512" @@ -42,8 +41,6 @@ import ( "golang.org/x/crypto/nacl/box" "golang.org/x/term" "gopkg.in/yaml.v3" - - "github.com/pelicanplatform/pelican/param" ) // If we prompted the user for a new password while setting up the file, @@ -380,17 +377,18 @@ func SaveConfigContents_internal(config *OSDFConfig, forcePassword bool) error { // Take a hash, and use the hash's bytes as the secret. func GetSecret() (string, error) { // Use issuer private key as the source to generate the secret - issuerKeyFile := param.IssuerKey.GetString() - err := GeneratePrivateKey(issuerKeyFile, elliptic.P256(), false) + privateKey, err := GetIssuerPrivateJWK() if err != nil { return "", err } - privateKey, err := LoadPrivateKey(issuerKeyFile, false) - if err != nil { + + // Extract the underlying ECDSA private key in native Go crypto key type + var rawKey interface{} + if err := privateKey.Raw(&rawKey); err != nil { return "", err } - derPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) + derPrivateKey, err := x509.MarshalPKCS8PrivateKey(rawKey) if err != nil { return "", err diff --git a/config/encrypted_test.go b/config/encrypted_test.go index bd100ea64..6fc3cefff 100644 --- a/config/encrypted_test.go +++ b/config/encrypted_test.go @@ -37,8 +37,8 @@ func TestGetSecret(t *testing.T) { }) t.Run("generate-32B-hash", func(t *testing.T) { tmp := t.TempDir() - keyName := filepath.Join(tmp, "issuer.key") - viper.Set(param.IssuerKey.GetName(), keyName) + keyDir := filepath.Join(tmp, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keyDir) get, err := GetSecret() require.NoError(t, err) @@ -55,8 +55,8 @@ func TestEncryptString(t *testing.T) { t.Run("encrypt-without-err", func(t *testing.T) { tmp := t.TempDir() - keyName := filepath.Join(tmp, "issuer.key") - viper.Set(param.IssuerKey.GetName(), keyName) + keyDir := filepath.Join(tmp, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keyDir) get, err := EncryptString("Some secret to encrypt") require.NoError(t, err) @@ -72,8 +72,8 @@ func TestDecryptString(t *testing.T) { }) t.Run("decrypt-without-err", func(t *testing.T) { tmp := t.TempDir() - keyName := filepath.Join(tmp, "issuer.key") - viper.Set(param.IssuerKey.GetName(), keyName) + keyDir := filepath.Join(tmp, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keyDir) secret := "Some secret to encrypt" @@ -88,8 +88,8 @@ func TestDecryptString(t *testing.T) { t.Run("diff-secrets-yield-diff-result", func(t *testing.T) { tmp := t.TempDir() - keyName := filepath.Join(tmp, "issuer.key") - viper.Set(param.IssuerKey.GetName(), keyName) + keyDir := filepath.Join(tmp, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keyDir) secret := "Some secret to encrypt" @@ -97,8 +97,9 @@ func TestDecryptString(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, getEncrypt) - newKeyName := filepath.Join(tmp, "new-issuer.key") - viper.Set(param.IssuerKey.GetName(), newKeyName) + ResetConfig() + newKeyDir := filepath.Join(tmp, "new-issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), newKeyDir) getDecrypt, err := DecryptString(getEncrypt) require.NoError(t, err) diff --git a/config/init_server_creds.go b/config/init_server_creds.go index 97b5bec8c..8ccfb832c 100644 --- a/config/init_server_creds.go +++ b/config/init_server_creds.go @@ -28,11 +28,13 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "io/fs" "math/big" "os" "os/exec" "path/filepath" "runtime" + "sync" "sync/atomic" "time" @@ -45,9 +47,20 @@ import ( ) var ( - // This is the private JWK for the server to sign tokens. This key remains - // the same if the IssuerKey is unchanged + // This is the private JWK for the server to sign tokens issuerPrivateJWK atomic.Pointer[jwk.Key] + + // This is the previous private JWK for the server to sign tokens (before current one) + previousIssuerPrivateJWK atomic.Pointer[jwk.Key] + + // Representing private keys (from all .pem files) in the directory cache + issuerPrivateKeys atomic.Pointer[map[string]jwk.Key] + + // Used to ensure initialization func init() is only called once + initOnce sync.Once + + // Prevent race condition in issuer's private key update + updateKeyMutex sync.Mutex ) // Reset the atomic pointer to issuer private jwk @@ -55,6 +68,75 @@ func ResetIssuerJWKPtr() { issuerPrivateJWK.Store(nil) } +// Get issuer's previous active private key +func GetPreviousIssuerPrivateJWK() jwk.Key { + previousKey := previousIssuerPrivateJWK.Load() + if previousKey == nil { + return nil + } + return *previousKey +} + +// Transfer the current issuer's private key to the previous one. This is called before updating +// the current key to maintain a reference to the previous key for previous public key validation. +func UpdatePreviousIssuerPrivateJWK() { + // ensure previousIssuerPrivateJWK isn't being updated concurrently + updateKeyMutex.Lock() + defer updateKeyMutex.Unlock() + + // Save the current key to previousIssuerPrivateJWK + currentKey := issuerPrivateJWK.Load() + if currentKey != nil { + previousIssuerPrivateJWK.Store(currentKey) + } +} + +// Reset the atomic pointer to issuer's private key +func ResetPreviousIssuerPrivateJWK() { + previousIssuerPrivateJWK.Store(nil) +} + +// Set a private key as the active private key in use +func SetActiveKey(key jwk.Key) { + issuerPrivateJWK.Store(&key) +} + +// Clear all entries from the issuerPrivateKeys +func ResetIssuerPrivateKeys() { + emptyMap := make(map[string]jwk.Key) + issuerPrivateKeys.Store(&emptyMap) +} + +// Safely load the current map and create a copy for modification +func getIssuerPrivateKeysCopy() map[string]jwk.Key { + currentKeys := issuerPrivateKeys.Load() + newMap := make(map[string]jwk.Key) + if currentKeys != nil { + for k, v := range *currentKeys { + newMap[k] = v + } + } + return newMap +} + +// Read the current map +func GetIssuerPrivateKeys() map[string]jwk.Key { + keysPtr := issuerPrivateKeys.Load() + return *keysPtr +} + +// Helper function to create a directory and set proper permissions to save private keys +func createDirForKeys(dir string) error { + gid, err := GetDaemonGID() + if err != nil { + return errors.Wrap(err, "failed to get deamon gid") + } + if err := MkdirAll(dir, 0750, -1, gid); err != nil { + return errors.Wrapf(err, "failed to set the permission of %s", dir) + } + return nil +} + // Return a pointer to an ECDSA private key or RSA private key read from keyLocation. // // This can be used to load ECDSA or RSA private key for various purposes, @@ -155,7 +237,7 @@ func GeneratePrivateKey(keyLocation string, curve elliptic.Curve, allowRSA bool) } // If we're generating a new key, log a warning in case the user intended to pass an existing key (maybe they made a typo) - log.Warningf("IssuerKey is set to %v but the file does not exist. Will generate a new private key", param.IssuerKey.GetString()) + log.Warningf("Will generate a new private key at location: %v", keyLocation) keyDir := filepath.Dir(keyLocation) if err := MkdirAll(keyDir, 0750, -1, gid); err != nil { @@ -503,44 +585,189 @@ func GenerateCert() error { return nil } -// Helper function to load the issuer/server's private key to sign tokens it issues. -// Only intended to be called internally -func loadIssuerPrivateJWK(issuerKeyFile string) (jwk.Key, error) { - // Check to see if we already had an IssuerKey or generate one - if err := GeneratePrivateKey(issuerKeyFile, elliptic.P256(), false); err != nil { - return nil, errors.Wrap(err, "Failed to generate new private key") - } - contents, err := os.ReadFile(issuerKeyFile) +// Helper function to initialize the in-memory map to save all private keys +func initKeysMap() error { + initialMap := make(map[string]jwk.Key) + issuerPrivateKeys.Store(&initialMap) + + return nil +} + +// Helper function to load one .pem file from specified filename +func loadSinglePEM(path string) (jwk.Key, error) { + contents, err := os.ReadFile(path) if err != nil { - return nil, errors.Wrap(err, "Failed to read issuer key file") + return nil, errors.Wrap(err, "failed to read key file") } + key, err := jwk.ParseKey(contents, jwk.WithPEM(true)) if err != nil { - return nil, errors.Wrapf(err, "Failed to parse issuer key file %v", issuerKeyFile) + return nil, errors.Wrapf(err, "failed to parse issuer key file %v", path) } // Add the algorithm to the key, needed for verifying tokens elsewhere - err = key.Set(jwk.AlgorithmKey, jwa.ES256) + if err := key.Set(jwk.AlgorithmKey, jwa.ES256); err != nil { + return nil, errors.Wrap(err, "failed to set algorithm") + } + + // Ensure key has an ID + if err := jwk.AssignKeyID(key); err != nil { + return nil, errors.Wrap(err, "failed to assign key ID") + } + + return key, nil +} + +// Helper function to load/refresh all key files from both legacy IssuerKey file and specified directory +// find the most recent private key based on lexicographical order of their filenames +func loadPEMFiles(dir string) (jwk.Key, error) { + var mostRecentKey jwk.Key + var mostRecentFileName string + latestKeys := getIssuerPrivateKeysCopy() + + // Load legacy private key if it exists - parsing the file at IssuerKey act as if it is included in IssuerKeysDirectory + issuerKeyPath := param.IssuerKey.GetString() + if issuerKeyPath != "" { + if _, err := os.Stat(issuerKeyPath); err == nil { + issuerKey, err := loadSinglePEM(issuerKeyPath) + if err != nil { + log.Warnf("Failed to load key %s: %v", issuerKeyPath, err) + } else { + latestKeys[issuerKey.KeyID()] = issuerKey + if mostRecentFileName == "" || filepath.Base(issuerKeyPath) > mostRecentFileName { + mostRecentFileName = filepath.Base(issuerKeyPath) + mostRecentKey = issuerKey + } + } + } + } + + // Ensure input directory dir exists, if not, create it with proper permissions + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := createDirForKeys(dir); err != nil { + return nil, errors.Wrapf(err, "failed to create directory and set permissions: %s", dir) + } + } + // Traverse the directory for .pem files in lexical order + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && filepath.Ext(d.Name()) == ".pem" { + // Parse the private key in this file and add to the in-memory keys map + key, err := loadSinglePEM(path) + if err != nil { + log.Warnf("Failed to load key %s: %v", path, err) + return nil // Skip this file and continue + } + + latestKeys[key.KeyID()] = key + + // Update the most recent key based on lexicographical order of filenames + if mostRecentFileName == "" || d.Name() > mostRecentFileName { + mostRecentFileName = d.Name() + mostRecentKey = key + } + } + return nil + }) + if err != nil { - return nil, errors.Wrap(err, "Failed to add alg specification to key header") + return nil, errors.Wrapf(err, "failed to traverse directory %s that stores private keys", dir) } - // Assign key id to the private key so that the public key obtainer thereafter - // has the same kid - err = jwk.AssignKeyID(key) + // Create a new private key and set as active when neither legacy private key at IssuerKey + // nor any .pem file at IssuerKeysDirectory exists + if len(latestKeys) == 0 || mostRecentKey == nil { + newKey, err := GeneratePEMandSetActiveKey(dir) + if err != nil { + return nil, errors.Wrapf(err, "failed to create a new .pem file to save private key") + } + return newKey, nil + } + + // Save up-to-date private keys and the active key in memory + issuerPrivateKeys.Store(&latestKeys) + SetActiveKey(mostRecentKey) + log.Debugf("Set private key %s as active", mostRecentKey.KeyID()) + + return mostRecentKey, nil +} + +// Create a new .pem file (combining GeneratePrivateKey and LoadPrivateKey functions) +func GeneratePEM(dir string) (jwk.Key, error) { + // Generate a unique filename using a POSIX mkstemp-like logic + // Create a temp file, store its filename, then immediately delete this temp file + filenamePattern := fmt.Sprintf("pelican_generated_%d_*.pem", + time.Now().UnixNano()) + if err := createDirForKeys(dir); err != nil { + return nil, errors.Wrapf(err, "failed to create directory and set permissions: %s", dir) + } + tempFile, err := os.CreateTemp(dir, filenamePattern) if err != nil { - return nil, errors.Wrap(err, "Failed to assign key ID to private key") + return nil, errors.Wrap(err, "failed to remove temp file") + } + keyPath := tempFile.Name() + if err := tempFile.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close temp file") + } + if err := os.Remove(keyPath); err != nil { + return nil, errors.Wrap(err, "failed to remove temp file") } - // Store the key in the in-memory cache - issuerPrivateJWK.Store(&key) + if err := GeneratePrivateKey(keyPath, elliptic.P256(), false); err != nil { + return nil, errors.Wrapf(err, "failed to generate new private key at %s", keyPath) + } + key, err := loadSinglePEM(keyPath) + if err != nil { + log.Errorf("Failed to load key %s: %v", keyPath, err) + return nil, errors.Wrapf(err, "failed to load key from %s", keyPath) + } + + log.Debugf("Generated private key %s", key.KeyID()) return key, nil } +// Generate a new .pem file and then set the private key it contains as the active one +func GeneratePEMandSetActiveKey(dir string) (jwk.Key, error) { + newKey, err := GeneratePEM(dir) + if err != nil { + return nil, errors.Wrapf(err, "failed to create a new .pem file to save private key") + } + + // Save this new key in the in-memory map for all private keys + keysCopy := getIssuerPrivateKeysCopy() + keysCopy[newKey.KeyID()] = newKey + issuerPrivateKeys.Store(&keysCopy) + SetActiveKey(newKey) + + return newKey, nil +} + +// Re-scan the disk to load the issuer/server's private keys, return the active key to sign tokens it issues +func LoadIssuerPrivateKey(issuerKeysDir string) (jwk.Key, error) { + // Ensure initKeysMap is only called once across the program’s runtime + var initErr error + initOnce.Do(func() { + initErr = initKeysMap() + }) + if initErr != nil { + return nil, errors.Wrap(initErr, "failed to initialize and/or migrate existing private key file") + } + + activeKey, err := loadPEMFiles(issuerKeysDir) + if err != nil { + return nil, errors.Wrapf(err, "failed to re-scan %s to load .pem files and set the key in the most recent modified file as active private key", issuerKeysDir) + } + + return activeKey, err +} + // Helper function to load the issuer/server's public key for other servers // to verify the token signed by this server. Only intended to be called internally -func loadIssuerPublicJWKS(existingJWKS string, issuerKeyFile string) (jwk.Set, error) { +func loadIssuerPublicJWKS(existingJWKS string, issuerKeysDir string) (jwk.Set, error) { jwks := jwk.NewSet() if existingJWKS != "" { var err error @@ -552,8 +779,8 @@ func loadIssuerPublicJWKS(existingJWKS string, issuerKeyFile string) (jwk.Set, e key := issuerPrivateJWK.Load() if key == nil { // This returns issuerPrivateJWK if it's non-nil, or find and parse private JWK - // located at IssuerKey if there is one, or generate a new private key - loadedKey, err := loadIssuerPrivateJWK(issuerKeyFile) + // located at IssuerKeysDirectory if there is one, or generate a new private key + loadedKey, err := LoadIssuerPrivateKey(issuerKeysDir) if err != nil { return nil, errors.Wrap(err, "Failed to load issuer private JWK") } @@ -562,7 +789,7 @@ func loadIssuerPublicJWKS(existingJWKS string, issuerKeyFile string) (jwk.Set, e pkey, err := jwk.PublicKeyOf(*key) if err != nil { - return nil, errors.Wrapf(err, "Failed to generate public key from file %v", issuerKeyFile) + return nil, errors.Wrapf(err, "failed to generate public key from file %v", issuerKeysDir) } if err = jwks.AddKey(pkey); err != nil { @@ -574,9 +801,11 @@ func loadIssuerPublicJWKS(existingJWKS string, issuerKeyFile string) (jwk.Set, e // Return the private JWK for the server to sign tokens func GetIssuerPrivateJWK() (jwk.Key, error) { key := issuerPrivateJWK.Load() + issuerKeysDir := param.IssuerKeysDirectory.GetString() + + // Re-scan the private keys dir when no active private key in memory if key == nil { - issuerKeyFile := param.IssuerKey.GetString() - newKey, err := loadIssuerPrivateJWK(issuerKeyFile) + newKey, err := LoadIssuerPrivateKey(issuerKeysDir) if err != nil { return nil, errors.Wrap(err, "Failed to load issuer private key") } @@ -595,8 +824,8 @@ func GetIssuerPrivateJWK() (jwk.Key, error) { // i.e. "/.well-known/issuer.jwks" func GetIssuerPublicJWKS() (jwk.Set, error) { existingJWKS := param.Server_IssuerJwks.GetString() - issuerKeyFile := param.IssuerKey.GetString() - return loadIssuerPublicJWKS(existingJWKS, issuerKeyFile) + issuerKeysDir := param.IssuerKeysDirectory.GetString() + return loadIssuerPublicJWKS(existingJWKS, issuerKeysDir) } // Check if there is a session secret exists at param.Server_SessionSecretFile and is not empty if there is one. diff --git a/config/init_server_creds_test.go b/config/init_server_creds_test.go index e29cc2f9d..6086cdbbd 100644 --- a/config/init_server_creds_test.go +++ b/config/init_server_creds_test.go @@ -150,3 +150,39 @@ func TestLoadPrivateKey(t *testing.T) { require.Nil(t, privateKey) }) } + +func TestMultiPrivateKey(t *testing.T) { + t.Run("generate-and-load-single-key", func(t *testing.T) { + ResetConfig() + defer ResetConfig() + tempDir := t.TempDir() + issuerKeysDir := filepath.Join(tempDir, "issuer-keys") + + key, err := LoadIssuerPrivateKey(issuerKeysDir) + require.NoError(t, err) + require.NotNil(t, key) + }) + + // This test also imitates the origin API endpoint "/newIssuerKey" + t.Run("second-private-key", func(t *testing.T) { + ResetConfig() + defer ResetConfig() + tempDir := t.TempDir() + issuerKeysDir := filepath.Join(tempDir, "issuer-keys") + + key, err := LoadIssuerPrivateKey(issuerKeysDir) + require.NoError(t, err) + require.NotNil(t, key) + + // Create another private key + secondKey, err := GeneratePEMandSetActiveKey(issuerKeysDir) + require.NoError(t, err) + require.NotNil(t, secondKey) + assert.NotEqual(t, key.KeyID(), secondKey.KeyID()) + + // Check if the active private key points to the latest key + latestKey, err := GetIssuerPrivateJWK() + require.NoError(t, err) + assert.Equal(t, secondKey.KeyID(), latestKey.KeyID()) + }) +} diff --git a/director/director_test.go b/director/director_test.go index 31597d9ac..fee5dfdb4 100644 --- a/director/director_test.go +++ b/director/director_test.go @@ -45,6 +45,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" @@ -1272,8 +1273,8 @@ func TestDiscoverOriginCache(t *testing.T) { viper.Set("Server.ExternalWebUrl", mockDirectorUrl) tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") - viper.Set("IssuerKey", kfile) + kDir := filepath.Join(tDir, "testKeyDir") + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("ConfigDir", t.TempDir()) config.InitConfig() diff --git a/director/origin_api_test.go b/director/origin_api_test.go index 1c3c350d7..5f676d305 100644 --- a/director/origin_api_test.go +++ b/director/origin_api_test.go @@ -35,6 +35,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" @@ -50,10 +51,10 @@ func TestVerifyAdvertiseToken(t *testing.T) { server_utils.ResetTestState() tDir := t.TempDir() - kfile := filepath.Join(tDir, "t-key") + kDir := filepath.Join(tDir, "t-issuer-keys") //Setup a private key and a token - viper.Set("IssuerKey", kfile) + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("Federation.DirectorURL", "https://director-url.org") diff --git a/director/stat_test.go b/director/stat_test.go index 49ea263ef..d5270c70b 100644 --- a/director/stat_test.go +++ b/director/stat_test.go @@ -36,6 +36,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/utils" @@ -827,8 +828,8 @@ func TestSendHeadReq(t *testing.T) { mockOriginAd.URL = *realServerUrl tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") - viper.Set("IssuerKey", kfile) + kDir := filepath.Join(tDir, "testKeyDir") + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("ConfigDir", t.TempDir()) config.InitConfig() diff --git a/docs/parameters.yaml b/docs/parameters.yaml index f086ca5ac..9e38581ab 100644 --- a/docs/parameters.yaml +++ b/docs/parameters.yaml @@ -64,14 +64,29 @@ components: ["origin", "registry", "director"] --- name: IssuerKey description: |+ + [Deprecated] Use IssuerKeysDirectory instead. The key file set by this parameter will automatically move to IssuerKeysDirectory. + A filepath to the file containing a PEM-encoded ecdsa private key which later will be parsed into a JWK and serves as the private key to sign various JWTs issued by this server. A public JWK will be derived from this private key and used as the key for token verification. type: filename +deprecated: true +replacedby: none root_default: /etc/pelican/issuer.jwk default: $ConfigBase/issuer.jwk -components: ["client", "registry", "director"] +components: ["origin", "cache", "registry", "director"] +--- +name: IssuerKeysDirectory +description: |+ + A filepath to the directory used for storing one or multiple PEM-encoded ecdsa private keys. The most recent modified + private key will be parsed into a JWK and serves as the active private key to sign various JWTs issued by this server. + + A public JWK will be derived from this private key and used as the key for token verification. +type: filename +root_default: /etc/pelican/issuer-keys +default: $ConfigBase/issuer-keys +components: ["origin", "cache", "registry", "director"] --- name: Transport.DialerTimeout description: |+ diff --git a/launcher_utils/register_namespace_test.go b/launcher_utils/register_namespace_test.go index f6e273b63..19aead30d 100644 --- a/launcher_utils/register_namespace_test.go +++ b/launcher_utils/register_namespace_test.go @@ -46,6 +46,11 @@ import ( ) func TestRegistration(t *testing.T) { + t.Cleanup(func() { + server_utils.ResetTestState() + config.ResetIssuerJWKPtr() + config.ResetIssuerPrivateKeys() + }) // Use a temp os directory to better control the deletion of the directory. // Fixes issue on Windows where we are trying to delete a file in use so this // better waits for the file/process to be shut down before deletion @@ -59,6 +64,8 @@ func TestRegistration(t *testing.T) { server_utils.ResetTestState() viper.Set("ConfigDir", tempConfigDir) + keysDir := filepath.Join(tempConfigDir, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keysDir) config.InitConfig() viper.Set("Registry.DbLocation", filepath.Join(tempConfigDir, "test.sql")) @@ -159,10 +166,157 @@ func TestRegistration(t *testing.T) { assert.NoError(t, err) assert.Equal(t, keyStatus, noKeyPresent) - // Redo the namespace prep, ensure that isPresent is true + // Redo the namespace prep, ensure that isRegistered is true prefix = param.Origin_FederationPrefix.GetString() _, registerURL, isRegistered, err = registerNamespacePrep(ctx, prefix) + assert.True(t, isRegistered) assert.Equal(t, svr.URL+"/api/v1.0/registry", registerURL) assert.NoError(t, err) +} + +func TestMultiKeysRegistration(t *testing.T) { + t.Cleanup(func() { + server_utils.ResetTestState() + config.ResetIssuerJWKPtr() + config.ResetIssuerPrivateKeys() + }) + // Use a temp os directory to better control the deletion of the directory. + // Fixes issue on Windows where we are trying to delete a file in use so this + // better waits for the file/process to be shut down before deletion + tempConfigDir, err := os.MkdirTemp("", "test") + assert.NoError(t, err) + defer os.RemoveAll(tempConfigDir) + + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) + defer func() { require.NoError(t, egrp.Wait()) }() + defer cancel() + + server_utils.ResetTestState() + viper.Set("ConfigDir", tempConfigDir) + keysDir := filepath.Join(tempConfigDir, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keysDir) + + config.InitConfig() + viper.Set("Registry.DbLocation", filepath.Join(tempConfigDir, "test.sql")) + err = config.InitServer(ctx, server_structs.OriginType) + require.NoError(t, err) + + err = registry.InitializeDB() + require.NoError(t, err) + defer func() { + err := registry.ShutdownRegistryDB() + assert.NoError(t, err) + }() + + gin.SetMode(gin.TestMode) + engine := gin.Default() + + // Ensure we have a issuer key + _, err = config.GetIssuerPublicJWKS() + require.NoError(t, err) + privKey, err := config.GetIssuerPrivateJWK() + require.NoError(t, err) + key, err := privKey.PublicKey() + require.NoError(t, err) + assert.NoError(t, jwk.AssignKeyID(key)) + keyId := key.KeyID() + require.NotEmpty(t, keyId) + keysMap := config.GetIssuerPrivateKeys() + require.Equal(t, 1, len(keysMap)) + + // Create a new issuer key and rotate out the old one + secondKey, err := config.GeneratePEMandSetActiveKey(keysDir) + require.NoError(t, err) + require.NotEqual(t, privKey.KeyID(), secondKey.KeyID()) + secondPubKey, err := secondKey.PublicKey() + require.NoError(t, err) + activeKey, err := config.GetIssuerPrivateJWK() + require.NoError(t, err) + require.Equal(t, secondKey, activeKey) + keysMap = config.GetIssuerPrivateKeys() + require.Equal(t, secondKey, keysMap[secondKey.KeyID()]) + require.Equal(t, privKey, keysMap[key.KeyID()]) + require.Equal(t, 2, len(keysMap)) + secondKeyId := secondKey.KeyID() + require.NotEmpty(t, keyId) + + //Configure registry + registry.RegisterRegistryAPI(engine.Group("/")) + + //Create a test HTTP server that sends requests to gin + svr := httptest.NewServer(engine) + defer svr.CloseClientConnections() + defer svr.Close() + + viper.Set("Federation.RegistryUrl", svr.URL) + viper.Set("Origin.FederationPrefix", "/test123") + + // Re-run the InitServer to reflect the new RegistryUrl set above + require.NoError(t, config.InitServer(ctx, server_structs.OriginType)) + + // Test registration succeeds + prefix := param.Origin_FederationPrefix.GetString() + key, registerURL, isRegistered, err := registerNamespacePrep(ctx, prefix) + require.NoError(t, err) + assert.False(t, isRegistered) + assert.Equal(t, registerURL, svr.URL+"/api/v1.0/registry") + err = registerNamespaceImpl(key, prefix, "mock_site_name", registerURL) + require.NoError(t, err) + + // Test we can query for the new key + req, err := http.NewRequest("GET", svr.URL+"/api/v1.0/registry", nil) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + tr := config.GetTransport() + client := &http.Client{Transport: tr} + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + // Test new key is the same one we registered. + entries := []server_structs.Namespace{} + err = json.Unmarshal(body, &entries) + require.NoError(t, err) + require.Equal(t, len(entries), 1) + assert.Equal(t, entries[0].Prefix, "/test123") + keySet, err := jwk.Parse([]byte(entries[0].Pubkey)) + require.NoError(t, err) + registryKey, isPresent := keySet.LookupKeyID(secondKeyId) + assert.True(t, isPresent) + assert.True(t, jwk.Equal(registryKey, secondPubKey)) + assert.Equal(t, "mock_site_name", entries[0].AdminMetadata.SiteName) + + // Test the functionality of the keyIsRegistered function + keyStatus, err := keyIsRegistered(key, svr.URL+"/api/v1.0/registry", "/test123") + assert.NoError(t, err) + require.Equal(t, keyStatus, keyMatch) + + // Generate a new key, test we get mismatch + privKeyAltRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + privKeyAlt, err := jwk.FromRaw(privKeyAltRaw) + require.NoError(t, err) + keyAlt, err := privKeyAlt.PublicKey() + require.NoError(t, err) + assert.NoError(t, jwk.AssignKeyID(keyAlt)) + keyStatus, err = keyIsRegistered(keyAlt, svr.URL+"/api/v1.0/registry", "/test123") + assert.NoError(t, err) + assert.Equal(t, keyStatus, keyMismatch) + + // Verify that no key is registered for an alternate prefix + keyStatus, err = keyIsRegistered(key, svr.URL, "test456") + assert.NoError(t, err) + assert.Equal(t, keyStatus, noKeyPresent) + + // Redo the namespace prep, ensure that isRegistered is true + prefix = param.Origin_FederationPrefix.GetString() + _, registerURL, isRegistered, err = registerNamespacePrep(ctx, prefix) + require.NoError(t, err) assert.True(t, isRegistered) + assert.Equal(t, svr.URL+"/api/v1.0/registry", registerURL) } diff --git a/launcher_utils/update_namespace_pubkey.go b/launcher_utils/update_namespace_pubkey.go new file mode 100644 index 000000000..1f8ff67b9 --- /dev/null +++ b/launcher_utils/update_namespace_pubkey.go @@ -0,0 +1,142 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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 launcher_utils + +import ( + "context" + "net/url" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" + + "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" + "github.com/pelicanplatform/pelican/registry" + "github.com/pelicanplatform/pelican/server_structs" + "github.com/pelicanplatform/pelican/server_utils" +) + +func updateNamespacesPubKeyPrep(ctx context.Context, prefixes []string) (jwk.Key, string, error) { + // Validate the namespace format + for _, prefix := range prefixes { + if prefix == "" { + err := errors.New("Invalid empty prefix for public key update") + return nil, "", err + } + if prefix[0] != '/' { + err := errors.New("Prefix specified for public key update must start with a '/'") + return nil, "", err + } + } + + // Generate the endpoint url that can update the public key of prefixes + fedInfo, err := config.GetFederation(ctx) + if err != nil { + return nil, "", err + } + registryEndpoint := fedInfo.RegistryEndpoint + if registryEndpoint == "" { + err = errors.New("No registry endpoint specified; try passing the `-f` flag specifying the federation name") + return nil, "", err + } + + prefixPubKeyUpdateUrl, err := url.JoinPath(registryEndpoint, "api", "v1.0", "registry", "updateNamespacesPubKey") + if err != nil { + err = errors.Wrap(err, "Failed to construct public key update endpoint URL: %v") + return nil, "", err + } + + // Obtain server's active private key + key, err := config.GetIssuerPrivateJWK() + if err != nil { + err = errors.Wrap(err, "Failed to obtain server's active private key") + return nil, "", err + } + + return key, prefixPubKeyUpdateUrl, nil +} + +func updateNamespacesPubKey(ctx context.Context, prefixes []string) error { + siteName := param.Xrootd_Sitename.GetString() + + key, url, err := updateNamespacesPubKeyPrep(ctx, prefixes) + if err != nil { + return err + } + if err = registry.NamespacesPubKeyUpdate(key, prefixes, siteName, url); err != nil { + return err + } + return nil +} + +// Check the issuer key directory containing .pem files every 5 minutes, load new private key(s) +// if new file(s) are detected, then register the new public key +func LaunchIssuerKeysDirRefresh(ctx context.Context, egrp *errgroup.Group) { + egrp.Go(func() error { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Debugln("Stopping periodic check for private keys directory.") + return nil + case <-ticker.C: + // Refresh the disk to pick up any new private key + config.UpdatePreviousIssuerPrivateJWK() + newKey, err := config.LoadIssuerPrivateKey(param.IssuerKeysDirectory.GetString()) + if err != nil { + return err + } + prevKeyID := config.GetPreviousIssuerPrivateJWK().KeyID() + newKeyID := newKey.KeyID() + + log.Debugf("Private keys directory refreshed successfully. Previous key: %s, New key: %s", prevKeyID, newKeyID) + if newKeyID == prevKeyID { + // Skip this iteration since the active key has not changed + log.Debugf("Active private key has not changed. Skipping update of namespaces' public keys in the registry database.") + continue + } + // Update public key in registry db when there new active private key + extUrlStr := param.Server_ExternalWebUrl.GetString() + extUrl, _ := url.Parse(extUrlStr) + namespace := server_structs.GetOriginNs(extUrl.Host) + if err := updateNamespacesPubKey(ctx, []string{namespace}); err != nil { + log.Errorf("Error updating the public key of the registered origin namespace %s: %v", namespace, err) + } + + originExports, err := server_utils.GetOriginExports() + if err != nil { + return err + } + originExportsNs := make([]string, len(originExports)) + for i, export := range originExports { + originExportsNs[i] = export.FederationPrefix + } + if err := updateNamespacesPubKey(ctx, originExportsNs); err != nil { + log.Errorf("Error updating the public key of origin-exported namespace(s): %v", err) + } + + } + } + }) +} diff --git a/launchers/origin_serve.go b/launchers/origin_serve.go index c07f0ea40..5672f2902 100644 --- a/launchers/origin_serve.go +++ b/launchers/origin_serve.go @@ -76,6 +76,10 @@ func OriginServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, origin.LaunchGlobusTokenRefresh(ctx, egrp) } + // Start a routine to periodically refresh the private key directory. + // This ensures that new or updated private keys are automatically loaded and registered + launcher_utils.LaunchIssuerKeysDirRefresh(ctx, egrp) + // Set up the APIs unrelated to UI, which only contains director-based health test reporting endpoint for now if err = origin.RegisterOriginAPI(engine, ctx, egrp); err != nil { return nil, err diff --git a/origin/origin_db_test.go b/origin/origin_db_test.go index 2e2a4d19f..f505a8143 100644 --- a/origin/origin_db_test.go +++ b/origin/origin_db_test.go @@ -62,8 +62,8 @@ func setupMockOriginDB(t *testing.T) { // Setup encryption tmp := t.TempDir() - keyName := filepath.Join(tmp, "issuer.key") - viper.Set(param.IssuerKey.GetName(), keyName) + keyDir := filepath.Join(tmp, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), keyDir) // Also update the refresh token to be encrpted for idx := range mockGC { diff --git a/param/parameters.go b/param/parameters.go index 5ff60fcb1..531b5e25a 100644 --- a/param/parameters.go +++ b/param/parameters.go @@ -56,6 +56,7 @@ func GetDeprecated() map[string][]string { "Director.EnableStat": {"Director.CheckOriginPresence"}, "DisableHttpProxy": {"Client.DisableHttpProxy"}, "DisableProxyFallback": {"Client.DisableProxyFallback"}, + "IssuerKey": {"none"}, "MinimumDownloadSpeed": {"Client.MinimumDownloadSpeed"}, "Origin.EnableDirListing": {"Origin.EnableListings"}, "Origin.EnableFallbackRead": {"Origin.EnableDirectReads"}, @@ -167,6 +168,7 @@ var ( Federation_TopologyNamespaceUrl = StringParam{"Federation.TopologyNamespaceUrl"} Federation_TopologyUrl = StringParam{"Federation.TopologyUrl"} IssuerKey = StringParam{"IssuerKey"} + IssuerKeysDirectory = StringParam{"IssuerKeysDirectory"} Issuer_AuthenticationSource = StringParam{"Issuer.AuthenticationSource"} Issuer_GroupFile = StringParam{"Issuer.GroupFile"} Issuer_GroupSource = StringParam{"Issuer.GroupSource"} diff --git a/param/parameters_struct.go b/param/parameters_struct.go index a835275a0..ed0856cc1 100644 --- a/param/parameters_struct.go +++ b/param/parameters_struct.go @@ -120,6 +120,7 @@ type Config struct { UserStripDomain bool `mapstructure:"userstripdomain" yaml:"UserStripDomain"` } `mapstructure:"issuer" yaml:"Issuer"` IssuerKey string `mapstructure:"issuerkey" yaml:"IssuerKey"` + IssuerKeysDirectory string `mapstructure:"issuerkeysdirectory" yaml:"IssuerKeysDirectory"` LocalCache struct { DataLocation string `mapstructure:"datalocation" yaml:"DataLocation"` HighWaterMarkPercentage int `mapstructure:"highwatermarkpercentage" yaml:"HighWaterMarkPercentage"` @@ -432,6 +433,7 @@ type configWithType struct { UserStripDomain struct { Type string; Value bool } } IssuerKey struct { Type string; Value string } + IssuerKeysDirectory struct { Type string; Value string } LocalCache struct { DataLocation struct { Type string; Value string } HighWaterMarkPercentage struct { Type string; Value int } diff --git a/registry/client_commands.go b/registry/client_commands.go index c56f481bf..87535143c 100644 --- a/registry/client_commands.go +++ b/registry/client_commands.go @@ -26,6 +26,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/lestrrat-go/jwx/v2/jwk" @@ -281,3 +282,153 @@ func NamespaceDelete(endpoint string, prefix string) error { fmt.Println(string(respData)) return nil } + +func NamespacesPubKeyUpdate(privateKey jwk.Key, prefixes []string, siteName string, namespacePubKeyUpdateEndpoint string) error { + publicKey, err := privateKey.PublicKey() + if err != nil { + return errors.Wrapf(err, "failed to generate public key for namespace registration") + } + if err = jwk.AssignKeyID(publicKey); err != nil { + return errors.Wrap(err, "failed to assign key ID to public key") + } + if err = publicKey.Set("alg", "ES256"); err != nil { + return errors.Wrap(err, "failed to assign signature algorithm to public key") + } + keySet := jwk.NewSet() + if err = keySet.AddKey(publicKey); err != nil { + return errors.Wrap(err, "failed to add public key to new JWKS") + } + + // Let's check that we can convert to JSON and get the right thing... + jsonbuf, err := json.Marshal(keySet) + if err != nil { + return errors.Wrap(err, "failed to marshal the public key into JWKS JSON") + } + log.Debugln("Constructed JWKS from loading public key:", string(jsonbuf)) + + clientNonce, err := generateNonce() + if err != nil { + return errors.Wrap(err, "failed to generate client nonce") + } + + data := map[string]interface{}{ + "client_nonce": clientNonce, + "pubkey": keySet, + } + + tr := config.GetTransport() + resp, err := utils.MakeRequest(context.Background(), tr, namespacePubKeyUpdateEndpoint, "POST", data, nil) + + var respData clientResponseData + // Handle case where there was an error encoded in the body + if err != nil { + if strings.Contains(err.Error(), "status code 404") { + log.Warnf("Registered namespace public key update endpoint returned 404 Not Found: %s. This endpoint is available in Pelican v7.12 or later.", namespacePubKeyUpdateEndpoint) + return nil + } + if unmarshalErr := json.Unmarshal(resp, &respData); unmarshalErr == nil { + return errors.Wrapf(err, "Server responded with an error: %s. %s", respData.Message, respData.Error) + } + return errors.Wrapf(err, "Server responded with an error and failed to parse JSON response from the server. Raw server response is %s", resp) + } + + // No error + if err = json.Unmarshal(resp, &respData); err != nil { + return errors.Wrapf(err, "Failure when parsing JSON response from the server with a successful request. Raw server response is %s", resp) + } + + // Create client payload by concatenating client_nonce and server_nonce + clientPayload := clientNonce + respData.ServerNonce + + // Sign the payload + privateKeyRaw := &ecdsa.PrivateKey{} + if err = privateKey.Raw(privateKeyRaw); err != nil { + return errors.Wrap(err, "failed to get an ECDSA private key") + } + signature, err := signPayload([]byte(clientPayload), privateKeyRaw) + if err != nil { + return errors.Wrap(err, "failed to sign payload") + } + // Also sign the payload with the previous private key + prevPrivateKey := config.GetPreviousIssuerPrivateJWK() + + var prevSignature []byte + if prevPrivateKey != nil { + prevPrivateKeyRaw := &ecdsa.PrivateKey{} + if err = prevPrivateKey.Raw(prevPrivateKeyRaw); err != nil { + return errors.Wrap(err, "failed to get previous raw private key in ECDSA") + } + prevSignature, err = signPayload([]byte(clientPayload), prevPrivateKeyRaw) + if err != nil { + return errors.Wrapf(err, "failed to sign payload with previous private key, previous signature is '%s'", prevSignature) + } + } + + // Send origins previous public key and all public keys in another key set + prevKey := config.GetPreviousIssuerPrivateJWK() + prevKeySet := jwk.NewSet() + if err = prevKeySet.AddKey(prevKey); err != nil { + return errors.Wrap(err, "failed to add previous public key to new JWKS") + } + + privateKeys := config.GetIssuerPrivateKeys() + if len(privateKeys) == 0 { + return errors.Wrap(err, "The server doesn't have any private key in memory") + } + allKeysSet := jwk.NewSet() + for _, privKey := range privateKeys { + pubKey, err := privKey.PublicKey() + if err != nil { + return errors.Wrapf(err, "failed to get the public key of a private key") + } + if err = allKeysSet.AddKey(pubKey); err != nil { + return errors.Wrap(err, "failed to add public key to all keys JWKS") + } + } + + // Create data for the second POST request + unidentifiedPayload := map[string]interface{}{ + "pubkey": keySet, + "prev_pubkey": prevKeySet, + "all_pubkeys": allKeysSet, + "prefixes": prefixes, + "site_name": siteName, + "client_nonce": clientNonce, + "server_nonce": respData.ServerNonce, + "client_payload": clientPayload, + "client_signature": hex.EncodeToString(signature), + "client_prev_signature": func() string { + if prevSignature == nil { + return "" + } + return hex.EncodeToString(prevSignature) + }(), + "server_payload": respData.ServerPayload, + "server_signature": respData.ServerSignature, + } + + // Send the second POST request + resp, err = utils.MakeRequest(context.Background(), tr, namespacePubKeyUpdateEndpoint, "POST", unidentifiedPayload, nil) + + // Handle case where there was an error encoded in the body + if unmarshalErr := json.Unmarshal(resp, &respData); unmarshalErr == nil { + if err != nil { + if strings.Contains(err.Error(), "status code 404") { + log.Warnf("Registered namespace public key update endpoint returned 404 Not Found: %s. This endpoint is available in Pelican v7.12 or later.", namespacePubKeyUpdateEndpoint) + return nil + } + log.Errorf("Server responded with an error: %v. %s. %s", respData.Message, respData.Error, err) + return errors.Wrapf(err, "Server responded with an error: %s. %s", respData.Message, respData.Error) + } + if respData.Message != "" { + log.Debugf("Server responded to registration confirmation successfully with message: %s", respData.Message) + } + } else { // Error decoding JSON + if err != nil { + return errors.Wrapf(err, "Server responded with an error and failed to parse JSON response from the server. Raw response is %s", resp) + } + return errors.Wrapf(unmarshalErr, "Failure when parsing JSON response from the server with a successful request. Raw server response is %s", resp) + } + + return nil +} diff --git a/registry/client_commands_test.go b/registry/client_commands_test.go index 7adc1c900..4ffb06ce5 100644 --- a/registry/client_commands_test.go +++ b/registry/client_commands_test.go @@ -26,28 +26,32 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "testing" "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/server_utils" "github.com/pelicanplatform/pelican/test_utils" ) func registryMockup(ctx context.Context, t *testing.T, testName string) *httptest.Server { + tDir := t.TempDir() + issuerTempDir := filepath.Join(tDir, testName) - issuerTempDir := filepath.Join(t.TempDir(), testName) - - ikey := filepath.Join(issuerTempDir, "issuer.jwk") - viper.Set("IssuerKey", ikey) + ikeyDir := filepath.Join(issuerTempDir, "issuer-keys") + viper.Set("IssuerKeysDirectory", ikeyDir) viper.Set("Registry.DbLocation", filepath.Join(issuerTempDir, "test.sql")) viper.Set("Server.WebPort", 8444) - config.InitConfigDir(viper.GetViper()) + viper.Set("ConfigDir", tDir) + config.InitConfig() err := config.InitServer(ctx, server_structs.RegistryType) require.NoError(t, err) @@ -66,11 +70,38 @@ func registryMockup(ctx context.Context, t *testing.T, testName string) *httptes return svr } +func getSortedKids(ctx context.Context, jsonStr string) ([]string, error) { + set, err := jwk.Parse([]byte(jsonStr)) + if err != nil { + return nil, err + } + var kids []string + keysIter := set.Keys(ctx) + for keysIter.Next(ctx) { + key := keysIter.Pair().Value.(jwk.Key) + + kid, ok := key.Get("kid") + if !ok { + continue + } + kids = append(kids, kid.(string)) + } + sort.Strings(kids) + + return kids, nil +} + func TestServeNamespaceRegistry(t *testing.T) { ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) - defer func() { require.NoError(t, egrp.Wait()) }() - defer cancel() - + t.Cleanup(func() { + if r := recover(); r != nil { + t.Errorf("Test panicked: %v", r) + } + + cancel() + assert.NoError(t, egrp.Wait()) + server_utils.ResetTestState() + }) server_utils.ResetTestState() svr := registryMockup(ctx, t, "serveregistry") @@ -89,6 +120,7 @@ func TestServeNamespaceRegistry(t *testing.T) { //Test functionality of registering a namespace (without identity) err = NamespaceRegister(privKey, svr.URL+"/api/v1.0/registry", "", "/foo/bar", "mock_site_name") require.NoError(t, err) + var privKey2 jwk.Key //Test we can list the namespace without an error t.Run("Test namespace list", func(t *testing.T) { @@ -122,6 +154,43 @@ func TestServeNamespaceRegistry(t *testing.T) { assert.Equal(t, ns.AdminMetadata.SiteName, "mock_site_name") }) + t.Run("test-registered-namespace-pubkey-update-with-new-active-key", func(t *testing.T) { + activeKey, err := config.GetIssuerPrivateJWK() + require.NoError(t, err) + require.Equal(t, privKey.KeyID(), activeKey.KeyID()) + + // Imitate LaunchIssuerKeysDirRefresh function + config.UpdatePreviousIssuerPrivateJWK() + _, err = config.GeneratePEM(param.IssuerKeysDirectory.GetString()) + require.NoError(t, err) + privKey2, err = config.LoadIssuerPrivateKey(param.IssuerKeysDirectory.GetString()) + require.NoError(t, err) + err = NamespacesPubKeyUpdate(privKey2, []string{"/foo/bar"}, "mock_site_name", svr.URL+"/api/v1.0/registry/updateNamespacesPubKey") + require.NoError(t, err) + }) + + t.Run("test-registered-namespace-pubkey-update-with-nonsense-key", func(t *testing.T) { + tempDir := filepath.Join(t.TempDir(), "in_the_middle_of_nowhere") + privKey3, err := config.GeneratePEM(tempDir) + require.NoError(t, err) + err = NamespacesPubKeyUpdate(privKey3, []string{"/foo/bar"}, "mock_site_name", svr.URL+"/api/v1.0/registry/updateNamespacesPubKey") + require.ErrorContains(t, err, "it doesn't contain any public key matching the existing namespace's public key in db") + }) + + t.Run("test-registered-namespace-pubkey-update-with-imposter-key", func(t *testing.T) { + privKey4, err := config.GeneratePEMandSetActiveKey(param.IssuerKeysDirectory.GetString()) + require.NoError(t, err) + config.UpdatePreviousIssuerPrivateJWK() + // Both active key and previous key are set to privKey4 + err = NamespacesPubKeyUpdate(privKey4, []string{"/foo/bar"}, "mock_site_name", svr.URL+"/api/v1.0/registry/updateNamespacesPubKey") + require.ErrorContains(t, err, "it fails to pass the proof of possession verification") + + // Revert the active key changes happened in this subtest + config.SetActiveKey(privKey) + config.UpdatePreviousIssuerPrivateJWK() + config.SetActiveKey(privKey2) + }) + t.Run("Test namespace delete", func(t *testing.T) { //Test functionality of namespace delete err = NamespaceDelete(svr.URL+"/api/v1.0/registry/foo/bar", "/foo/bar") @@ -143,18 +212,118 @@ func TestServeNamespaceRegistry(t *testing.T) { server_utils.ResetTestState() } -func TestRegistryKeyChainingOSDF(t *testing.T) { +func TestMultiPubKeysRegisteredOnNamespace(t *testing.T) { ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) - defer func() { require.NoError(t, egrp.Wait()) }() - defer cancel() + server_utils.ResetTestState() + t.Cleanup(func() { + if r := recover(); r != nil { + t.Errorf("Test panicked: %v", r) + } + + cancel() + assert.NoError(t, egrp.Wait()) + server_utils.ResetTestState() + }) + + tDir := t.TempDir() + + svr := registryMockup(ctx, t, "MultiPubKeysRegisteredOnNamespace") + defer func() { + err := ShutdownRegistryDB() + assert.NoError(t, err) + svr.CloseClientConnections() + svr.Close() + }() + + config.ResetIssuerJWKPtr() + config.ResetIssuerPrivateKeys() + privKeys := config.GetIssuerPrivateKeys() + require.Len(t, privKeys, 0) + + // Construct a client that has [p1,p2,p3] and p3 is the active private key + privKey1, err := config.GeneratePEMandSetActiveKey(param.IssuerKeysDirectory.GetString()) + require.NotEmpty(t, privKey1) + require.NoError(t, err) + privKey2, err := config.GeneratePEMandSetActiveKey(param.IssuerKeysDirectory.GetString()) + require.NoError(t, err) + + prefix := "/mascot/bucky" + err = NamespaceRegister(privKey2, svr.URL+"/api/v1.0/registry", "", prefix, "mock_site_name") + require.NoError(t, err) + + config.UpdatePreviousIssuerPrivateJWK() + privKey3, err := config.GeneratePEMandSetActiveKey(param.IssuerKeysDirectory.GetString()) + require.NoError(t, err) + + // Construct a public keys JWKS [p2,p4] to save in registry DB, imitating admin manually adding p4 + registryDbJwks := jwk.NewSet() + pubKey2, err := jwk.PublicKeyOf(privKey2) + require.NoError(t, err) + err = registryDbJwks.AddKey(pubKey2) + require.NoError(t, err) + privKey4, err := config.GeneratePEM(filepath.Join(tDir, "elsewhere")) + require.NoError(t, err) + pubKey4, err := jwk.PublicKeyOf(privKey4) + require.NoError(t, err) + err = registryDbJwks.AddKey(pubKey4) + require.NoError(t, err) + jwksBytes, err := json.Marshal(registryDbJwks) + require.NoError(t, err) + jwksStr := string(jwksBytes) + // Test functionality of a namespace registered with multi public keys [p2,p4] + err = setNamespacePubKey(prefix, jwksStr) // set the registered public keys to [p2,p4] + require.NoError(t, err) + ns, err := getNamespaceByPrefix(prefix) + require.NoError(t, err) + require.Equal(t, jwksStr, ns.Pubkey) + + prevKey := config.GetPreviousIssuerPrivateJWK() + require.Equal(t, privKey2.KeyID(), prevKey.KeyID()) + privKeys = config.GetIssuerPrivateKeys() + require.Len(t, privKeys, 3) + + // Client allKeys:[p1,p2,p3] prevKey:p2 activeKey:p3 ---UPDATE--> Registry [p2,p4] + // => should update Registry to [p3,p4] (rotate out prevKey:p2, rotate in activeKey:p3) + err = NamespacesPubKeyUpdate(privKey3, []string{prefix}, "mock_site_name", svr.URL+"/api/v1.0/registry/updateNamespacesPubKey") + require.NoError(t, err) + ns, err = getNamespaceByPrefix(prefix) + require.NoError(t, err) + + expectedJwks := jwk.NewSet() + pubKey3, err := jwk.PublicKeyOf(privKey3) + require.NoError(t, err) + err = expectedJwks.AddKey(pubKey3) + require.NoError(t, err) + err = expectedJwks.AddKey(pubKey4) + require.NoError(t, err) + expectedJwksBytes, err := json.Marshal(expectedJwks) + require.NoError(t, err) + expectedJwksStr := string(expectedJwksBytes) + + expectedKids, err := getSortedKids(ctx, expectedJwksStr) + require.NoError(t, err) + actualKids, err := getSortedKids(ctx, ns.Pubkey) + require.NoError(t, err) + require.Equal(t, expectedKids, actualKids) +} + +func TestRegistryKeyChainingOSDF(t *testing.T) { server_utils.ResetTestState() + + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) + t.Cleanup(func() { + if r := recover(); r != nil { + t.Errorf("Test panicked: %v", r) + } + + cancel() + assert.NoError(t, egrp.Wait()) + server_utils.ResetTestState() + }) + _, err := config.SetPreferredPrefix(config.OsdfPrefix) assert.NoError(t, err) - viper.Set("Federation.DirectorUrl", "https://osdf-director.osg-htc.org") - viper.Set("Federation.RegistryUrl", "https://osdf-registry.osg-htc.org") - viper.Set("Federation.JwkUrl", "https://osg-htc.org/osdf/public_signing_key.jwks") - viper.Set("Federation.BrokerUrl", "https://osdf-director.osg-htc.org") // On by default, but just to make things explicit viper.Set("Registry.RequireKeyChaining", true) @@ -176,10 +345,10 @@ func TestRegistryKeyChainingOSDF(t *testing.T) { topoSvr.Close() }() - _, err = config.GetIssuerPublicJWKS() - require.NoError(t, err) privKey, err := config.GetIssuerPrivateJWK() require.NoError(t, err) + _, err = config.GetIssuerPublicJWKS() + require.NoError(t, err) // Start by registering /foo/bar with the default key err = NamespaceRegister(privKey, registrySvr.URL+"/api/v1.0/registry", "", "/foo/bar", "") @@ -200,16 +369,19 @@ func TestRegistryKeyChainingOSDF(t *testing.T) { assert.Contains(t, err.Error(), "A superspace or subspace of this namespace /topo/foo already exists in the OSDF topology: /topo/foo. To register a Pelican equivalence, you need to present your identity.") // Now we create a new key and try to use it to register a super/sub space. These shouldn't succeed - viper.Set("IssuerKey", t.TempDir()+"/keychaining") - viper.Set("ConfigDir", t.TempDir()) + config.ResetIssuerJWKPtr() + config.ResetIssuerPrivateKeys() + tDir2 := t.TempDir() + viper.Set("IssuerKeysDirectory", tDir2+"/keychaining2") + viper.Set("ConfigDir", tDir2) config.InitConfig() err = config.InitServer(ctx, server_structs.RegistryType) require.NoError(t, err) - _, err = config.GetIssuerPublicJWKS() - require.NoError(t, err) privKey, err = config.GetIssuerPrivateJWK() require.NoError(t, err) + _, err = config.GetIssuerPublicJWKS() + require.NoError(t, err) err = NamespaceRegister(privKey, registrySvr.URL+"/api/v1.0/registry", "", "/foo/bar/baz", "") require.ErrorContains(t, err, "Cannot register a namespace that is suffixed or prefixed") @@ -242,11 +414,19 @@ func TestRegistryKeyChainingOSDF(t *testing.T) { } func TestRegistryKeyChaining(t *testing.T) { + server_utils.ResetTestState() + ctx, cancel, egrp := test_utils.TestContext(context.Background(), t) - defer func() { require.NoError(t, egrp.Wait()) }() - defer cancel() + t.Cleanup(func() { + if r := recover(); r != nil { + t.Errorf("Test panicked: %v", r) + } + + cancel() + assert.NoError(t, egrp.Wait()) + server_utils.ResetTestState() + }) - server_utils.ResetTestState() // On by default, but just to make things explicit viper.Set("Registry.RequireKeyChaining", true) @@ -258,10 +438,10 @@ func TestRegistryKeyChaining(t *testing.T) { registrySvr.Close() }() - _, err := config.GetIssuerPublicJWKS() - require.NoError(t, err) privKey, err := config.GetIssuerPrivateJWK() require.NoError(t, err) + _, err = config.GetIssuerPublicJWKS() + require.NoError(t, err) // Start by registering /foo/bar with the default key err = NamespaceRegister(privKey, registrySvr.URL+"/api/v1.0/registry", "", "/foo/bar", "") @@ -272,16 +452,16 @@ func TestRegistryKeyChaining(t *testing.T) { require.NoError(t, err) // Now we create a new key and try to use it to register a super/sub space. These shouldn't succeed - viper.Set("IssuerKey", t.TempDir()+"/keychaining") + viper.Set("IssuerKeysDirectory", t.TempDir()+"/keychaining2") viper.Set("ConfigDir", t.TempDir()) config.InitConfig() err = config.InitServer(ctx, server_structs.RegistryType) require.NoError(t, err) - _, err = config.GetIssuerPublicJWKS() - require.NoError(t, err) privKey, err = config.GetIssuerPrivateJWK() require.NoError(t, err) + _, err = config.GetIssuerPublicJWKS() + require.NoError(t, err) err = NamespaceRegister(privKey, registrySvr.URL+"/api/v1.0/registry", "", "/foo/bar/baz", "") require.ErrorContains(t, err, "Cannot register a namespace that is suffixed or prefixed") @@ -296,6 +476,4 @@ func TestRegistryKeyChaining(t *testing.T) { err = NamespaceRegister(privKey, registrySvr.URL+"/api/v1.0/registry", "", "/foo", "") require.NoError(t, err) - - server_utils.ResetTestState() } diff --git a/registry/registry.go b/registry/registry.go index 077ba917e..c63ca6492 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -173,16 +173,26 @@ func loadServerKeys() (*ecdsa.PrivateKey, error) { // Note: go 1.21 introduces `OnceValues` which automates this procedure. // TODO: Reimplement the function once we switch to a minimum of 1.21 serverCredsLoad.Do(func() { - issuerFileName := param.IssuerKey.GetString() - var privateKey crypto.PrivateKey - privateKey, serverCredsErr = config.LoadPrivateKey(issuerFileName, false) - - switch key := privateKey.(type) { - case *ecdsa.PrivateKey: - serverCredsPrivKey = key - default: - serverCredsErr = errors.Errorf("unsupported key type for server issuer key: %T", key) + var privateKey jwk.Key + privateKey, serverCredsErr = config.GetIssuerPrivateJWK() + if serverCredsErr != nil { + return + } + + // Extract the underlying ECDSA private key + var rawKey interface{} + if err := privateKey.Raw(&rawKey); err != nil { + serverCredsErr = errors.Errorf("failed to extract raw key: %v", err) + return } + + ecdsaKey, ok := rawKey.(*ecdsa.PrivateKey) + if !ok { + serverCredsErr = errors.Errorf("unsupported key type for server issuer key: %T", rawKey) + return + } + + serverCredsPrivKey = ecdsaKey }) return serverCredsPrivKey, serverCredsErr @@ -1168,6 +1178,7 @@ func RegisterRegistryAPI(router *gin.RouterGroup) { registryAPI.GET("/*wildcard", wildcardHandler) registryAPI.POST("/checkNamespaceExists", checkNamespaceExistsHandler) registryAPI.POST("/checkNamespaceStatus", checkApprovalHandler) + registryAPI.POST("/updateNamespacesPubKey", updateNamespacesPubKey) registryAPI.DELETE("/*wildcard", deleteNamespaceHandler) } diff --git a/registry/registry_db.go b/registry/registry_db.go index 3d8afb9bc..f05565bcc 100644 --- a/registry/registry_db.go +++ b/registry/registry_db.go @@ -382,7 +382,6 @@ func AddNamespace(ns *server_structs.Namespace) error { if ns.AdminMetadata.Status == "" { ns.AdminMetadata.Status = server_structs.RegPending } - return db.Save(&ns).Error } @@ -439,6 +438,17 @@ func updateNamespaceStatusById(id int, status server_structs.RegistrationStatus, return db.Model(ns).Where("id = ?", id).Update("admin_metadata", string(adminMetadataByte)).Error } +func setNamespacePubKey(prefix string, pubkeyDbString string) error { + if prefix == "" { + return errors.New("invalid prefix. Prefix must not be empty") + } + if pubkeyDbString == "" { + return errors.New("invalid pubkeyDbString. pubkeyDbString must not be empty") + } + ns := server_structs.Namespace{} + return db.Model(ns).Where("prefix = ? ", prefix).Update("pubkey", pubkeyDbString).Error +} + func deleteNamespaceByID(id int) error { return db.Delete(&server_structs.Namespace{}, id).Error } diff --git a/registry/registry_pubkey_update.go b/registry/registry_pubkey_update.go new file mode 100644 index 000000000..89d7beaba --- /dev/null +++ b/registry/registry_pubkey_update.go @@ -0,0 +1,359 @@ +/*************************************************************** + * + * Copyright (C) 2024, Pelican Project, Morgridge Institute for Research + * + * 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 registry + +import ( + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "github.com/pelicanplatform/pelican/server_structs" +) + +type RegisteredPrefixUpdate struct { + ClientNonce string `json:"client_nonce"` + ClientPayload string `json:"client_payload"` + ClientSignature string `json:"client_signature"` + ClientPrevSignature string `json:"client_prev_signature"` + + ServerNonce string `json:"server_nonce"` + ServerPayload string `json:"server_payload"` + ServerSignature string `json:"server_signature"` + + Pubkey json.RawMessage `json:"pubkey"` + PrevPubkey json.RawMessage `json:"prev_pubkey"` + AllPubkeys json.RawMessage `json:"all_pubkeys"` + Prefixes []string `json:"prefixes"` +} + +func getKeyByKid(keySet jwk.Set, kidToFind string) (jwk.Key, error) { + for i := 0; i < keySet.Len(); i++ { + key, _ := keySet.Key(i) + kid, ok := key.Get("kid") + if ok && kid == kidToFind { + return key, nil + } + } + return nil, fmt.Errorf("key with kid %s not found in the set", kidToFind) +} + +func constructUpdatedKeySet(keySet jwk.Set, kidToRemove string, keyToAdd jwk.Key) error { + keyToRemove, err := getKeyByKid(keySet, kidToRemove) + if err != nil { + log.Warnf("Key %s not found in the key set, skipping removal: %v", kidToRemove, err) + } else { + // Remove the key only if it exists + if err := keySet.RemoveKey(keyToRemove); err != nil { + return errors.Wrap(err, "failed to remove key from set") + } + } + if err := keySet.AddKey(keyToAdd); err != nil { + return errors.Wrap(err, "failed to add key to the key set") + } + return nil +} + +// Generate server nonce for key-sign challenge when updating the public key of registered namespace(s) +func updateNsKeySignChallengeInit(data *RegisteredPrefixUpdate) (map[string]interface{}, error) { + serverNonce, err := generateNonce() + if err != nil { + return nil, errors.Wrap(err, "Failed to generate nonce for key-sign challenge") + } + + serverPayload := []byte(data.ClientNonce + data.ServerNonce) + + privateKey, err := loadServerKeys() + if err != nil { + return nil, errors.Wrap(err, "Server is unable to generate a key sign challenge: Failed to load the server's private key") + } + + serverSignature, err := signPayload(serverPayload, privateKey) + if err != nil { + return nil, errors.Wrap(err, "Failed to sign payload for key-sign challenge") + } + + res := map[string]interface{}{ + "server_nonce": serverNonce, + "client_nonce": data.ClientNonce, + "server_payload": hex.EncodeToString(serverPayload), + "server_signature": hex.EncodeToString(serverSignature), + } + return res, nil +} + +// Update the public key of registered prefix(es) if the http request passed client and server verification for nonce. +// It returns the response data, and an error if any +func updateNsKeySignChallengeCommit(ctx *gin.Context, data *RegisteredPrefixUpdate) (map[string]interface{}, error) { + // Validate the client's jwks as a set here + key, err := validateJwks(string(data.Pubkey)) + if err != nil { + return nil, badRequestError{Message: err.Error()} + } + var rawkey interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey + if err := key.Raw(&rawkey); err != nil { + return nil, errors.Wrap(err, "failed to generate raw pubkey from jwks") + } + + // Verify the Proof of Possession of the client and server's active private keys + clientPayload := []byte(data.ClientNonce + data.ServerNonce) + clientSignature, err := hex.DecodeString(data.ClientSignature) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode the client's signature") + } + clientVerified := verifySignature(clientPayload, clientSignature, (rawkey).(*ecdsa.PublicKey)) + serverPayload, err := hex.DecodeString(data.ServerPayload) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode the server's payload") + } + + serverSignature, err := hex.DecodeString(data.ServerSignature) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode the server's signature") + } + + serverPrivateKey, err := loadServerKeys() + if err != nil { + return nil, errors.Wrap(err, "Failed to decode the server's private key") + } + serverPubkey := serverPrivateKey.PublicKey + serverVerified := verifySignature(serverPayload, serverSignature, &serverPubkey) + + if clientVerified && serverVerified { + for _, prefix := range data.Prefixes { + log.Debug("Start updating namespace: ", prefix) + + // Check if prefix exists before doing anything else + exists, err := namespaceExistsByPrefix(prefix) + if err != nil { + log.Errorf("Failed to check if namespace already exists: %v", err) + return nil, errors.Wrap(err, "Server encountered an error checking if namespace already exists") + } + if exists { + // Update the namespace's public key with the latest one when authorized origin provides a new key + existingNs, err := getNamespaceByPrefix(prefix) + if err != nil { + log.Errorf("Failed to get existing namespace to update: %v", err) + return nil, errors.Wrap(err, "Server encountered an error getting existing namespace to update") + } + + // Check the origin is authorized to update (possessing the public key used for prefix initial registration) + // Parse all public keys of the sender into a JWKS + var clientKeySet jwk.Set + if data.AllPubkeys == nil { // backward compatibility - AllPubkeys only exists in the payload in Pelican 7.12 or later + clientKeySet, err = jwk.Parse(data.Pubkey) + } else { + clientKeySet, err = jwk.Parse(data.AllPubkeys) + } + if err != nil { + log.Errorf("Failed to parse in-memory public keys of the client: %v", err) + return nil, errors.Wrapf(err, "Invalid in-memory public keys format of the client") + } + // Parse `existingNs.Pubkey` as a JWKS + registryDbKeySet := jwk.NewSet() + err = json.Unmarshal([]byte(existingNs.Pubkey), ®istryDbKeySet) + if err != nil { + log.Errorf("Failed to parse existing namespace public key as JWKS: %v", err) + return nil, errors.Wrap(err, "Invalid existing namespace public key format") + } + + // Get client's previous signature from payload + prevKeyVerified := false + if data.ClientPrevSignature == "" { + prevKeyVerified = true + } + clientPrevSignature, err := hex.DecodeString(data.ClientPrevSignature) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode the client's previous signature") + } + + // Check if any key in `clientKeySet` matches a key in `registryDbKeySet` + registryDbKeysIter := registryDbKeySet.Keys(ctx) + clientKeysIter := clientKeySet.Keys(ctx) + matchFound := false + + for registryDbKeysIter.Next(ctx) { + registryDbKey := registryDbKeysIter.Pair().Value.(jwk.Key) + + registryDbKid, ok := registryDbKey.Get("kid") + if !ok { + log.Warnf("Skipping registry db existing key without 'kid'") + continue + } + + for clientKeysIter.Next(ctx) { + clientKey := clientKeysIter.Pair().Value.(jwk.Key) + + clientKid, ok := clientKey.Get("kid") + if !ok { + log.Warnf("Skipping client key without 'kid'") + continue + } + + if registryDbKid == clientKid { + // Verify the Proof of Possession of client's previous active private key + // Get client's previous public key recorded in db + var prevRawkey interface{} + if err := registryDbKey.Raw(&prevRawkey); err != nil { + return nil, errors.Wrap(err, "failed to generate raw pubkey from client's previous pubkey") + } + + if data.ClientPrevSignature != "" { + prevKeyVerified = verifySignature(clientPayload, clientPrevSignature, (prevRawkey).(*ecdsa.PublicKey)) + } + + if prevKeyVerified { + matchFound = true + break + } else { + log.Debugf("This key is not client's previous active key that it wants to rotate out, or client lacks proof of possession of claimed previous active key (kid: %s)", registryDbKid) + } + } + } + + if matchFound { + break + } + } + + if !matchFound { + return nil, permissionDeniedError{ + Message: fmt.Sprintf("The client that tries to prefix '%s' cannot be authorized: either it doesn't contain any public key matching the existing namespace's public key in db, or it fails to pass the proof of possession verification", prefix), + } + } + + log.Debugf("New public key %s is going to replace the old one: %s", string(data.Pubkey), existingNs.Pubkey) + + // Process origin's new public key + pubkeyData, err := json.Marshal(data.Pubkey) + if err != nil { + return nil, errors.Wrapf(err, "Failed to convert public key from json to string format for the prefix %s", prefix) + } + clientPubkeyString := string(pubkeyData) + + // Construct the updated JWKS to be stored in registry db + prevKey, _ := validateJwks(string(data.PrevPubkey)) + err = constructUpdatedKeySet(registryDbKeySet, prevKey.KeyID(), key) + if err != nil { + return nil, errors.Wrap(err, "failed to construct the updated JWKS to be stored in registry db") + } + registryDbKeySetJson, err := json.Marshal(registryDbKeySet) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal registryDbKeySet to JSON") + } + + // Perform the update action when origin provides a new key + if clientPubkeyString != existingNs.Pubkey { + err = setNamespacePubKey(prefix, string(registryDbKeySetJson)) + if err != nil { + log.Errorf("Failed to update the public key of namespace %s: %v", prefix, err) + return nil, errors.Wrap(err, "Server encountered an error updating the public key of an existing namespace") + } + returnMsg := map[string]interface{}{ + "message": fmt.Sprintf("Updated the public key of namespace %s:", prefix), + } + log.Infof("Updated the public key of namespace %s:", prefix) + return returnMsg, nil + } else { + returnMsg := map[string]interface{}{ + "message": fmt.Sprintf("The public key of prefix %s hasn't changed -- nothing to update!", prefix), + } + log.Infof("The public key of prefix %s hasn't changed -- nothing to update!", prefix) + return returnMsg, nil + } + } else { + log.Errorf("Prefix %s is not registered", prefix) + return nil, errors.Errorf("Prefix %s is not registered", prefix) + } + } + } + + return nil, errors.Errorf("Unable to verify the client's public key, or an encountered an error with its own: "+ + "server verified:%t, client verified:%t", serverVerified, clientVerified) + +} + +// Handle the registered namespace public key update with nonce generation and verifcation, regardless of +// using OIDC Authorization or not +func updateNsKeySignChallenge(ctx *gin.Context, data *RegisteredPrefixUpdate) (map[string]interface{}, error) { + if data.ClientNonce != "" && data.ClientPayload != "" && data.ClientSignature != "" && + data.ServerNonce != "" && data.ServerPayload != "" && data.ServerSignature != "" { + res, err := updateNsKeySignChallengeCommit(ctx, data) + if err != nil { + return nil, err + } else { + return res, nil + } + } else if data.ClientNonce != "" { + res, err := updateNsKeySignChallengeInit(data) + if err != nil { + return nil, err + } else { + return res, nil + } + } else { + return nil, badRequestError{Message: "Key sign challenge is missing parameters"} + } +} + +func updateNamespacesPubKey(ctx *gin.Context) { + + var reqData RegisteredPrefixUpdate + if err := ctx.BindJSON(&reqData); err != nil { + log.Errorln("Bad request: ", err) + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: fmt.Sprint("Bad Request: ", err.Error())}) + return + } + + res, err := updateNsKeySignChallenge(ctx, &reqData) + if err != nil { + if errors.As(err, &permissionDeniedError{}) { + ctx.JSON(http.StatusForbidden, + server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: fmt.Sprintf("You don't have permission to update the registered public key of the prefix: %v", err), + }) + return + } else if errors.As(err, &badRequestError{}) { + ctx.JSON(http.StatusBadRequest, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: fmt.Sprintf("Bad request for key-sign challenge: %v", err), + }) + return + } else { + ctx.JSON(http.StatusInternalServerError, server_structs.SimpleApiResp{ + Status: server_structs.RespFailed, + Msg: fmt.Sprintf("Server encountered an error during key-sign challenge: %v", err), + }) + log.Warningf("Failed to complete key sign challenge without identity requirement: %v", err) + return + } + } else { + ctx.JSON(http.StatusOK, res) + return + } +} diff --git a/token/token_create_test.go b/token/token_create_test.go index eb55c61e3..9d2249125 100644 --- a/token/token_create_test.go +++ b/token/token_create_test.go @@ -34,6 +34,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pelicanplatform/pelican/config" + "github.com/pelicanplatform/pelican/param" "github.com/pelicanplatform/pelican/server_structs" "github.com/pelicanplatform/pelican/test_utils" "github.com/pelicanplatform/pelican/token_scopes" @@ -208,8 +209,8 @@ func TestCreateToken(t *testing.T) { config.ResetConfig() viper.Set("IssuerUrl", "https://my-issuer.com") tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") - viper.Set("IssuerKey", kfile) + kDir := filepath.Join(tDir, "testKeyDir") + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("ConfigDir", t.TempDir()) config.InitConfig() err := config.InitServer(ctx, server_structs.DirectorType) diff --git a/web_ui/prometheus_test.go b/web_ui/prometheus_test.go index 22197aade..9995417d2 100644 --- a/web_ui/prometheus_test.go +++ b/web_ui/prometheus_test.go @@ -63,9 +63,9 @@ func TestPrometheusUnprotected(t *testing.T) { // Create temp dir for the origin key file tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") + kDir := filepath.Join(tDir, "testKeyDir") //Setup a private key - viper.Set("IssuerKey", kfile) + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("ConfigDir", t.TempDir()) config.InitConfig() err := config.InitServer(ctx, server_structs.OriginType) @@ -109,9 +109,9 @@ func TestPrometheusProtectionCookieAuth(t *testing.T) { // Create temp dir for the origin key file tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") - //Setup a private key - viper.Set("IssuerKey", kfile) + kDir := filepath.Join(tDir, "testKeyDir") + // Setup a private key directory + viper.Set(param.IssuerKeysDirectory.GetName(), kDir) viper.Set("ConfigDir", t.TempDir()) config.InitConfig() err := config.InitServer(ctx, server_structs.OriginType) @@ -170,10 +170,10 @@ func TestPrometheusProtectionOriginHeaderScope(t *testing.T) { // Create temp dir for the origin key file tDir := t.TempDir() - kfile := filepath.Join(tDir, "testKey") + keysDir := filepath.Join(tDir, "testKeys") //Setup a private key and a token - viper.Set("IssuerKey", kfile) + viper.Set("IssuerKeysDirectory", keysDir) // Setting the ConfigDir to t.TempDir() causes issues with this test on Windows because // the process tries to clean up the directory before the test is done with it. @@ -237,15 +237,15 @@ func TestPrometheusProtectionOriginHeaderScope(t *testing.T) { } // Create a new private key by re-initializing config to point at a new temp dir - k2file := filepath.Join(tDir, "testKey2") - viper.Set("IssuerKey", k2file) + k2dir := filepath.Join(tDir, "whatever", "testDir2") + viper.Set("IssuerKeysDirectory", k2dir) err = config.InitServer(ctx, server_structs.OriginType) require.NoError(t, err) token := createToken("monitoring.query", issuerUrl) // Re-init the config again, this time pointing at the original key - viper.Set("IssuerKey", kfile) + viper.Set("IssuerKeysDirectory", keysDir) err = config.InitServer(ctx, server_structs.OriginType) require.NoError(t, err) diff --git a/web_ui/ui_test.go b/web_ui/ui_test.go index a57c8e1e2..27ffe4526 100644 --- a/web_ui/ui_test.go +++ b/web_ui/ui_test.go @@ -96,7 +96,7 @@ func TestMain(m *testing.M) { } //Override viper default for testing - viper.Set("IssuerKey", filepath.Join(tempJWKDir, "issuer.jwk")) + viper.Set(param.IssuerKeysDirectory.GetName(), filepath.Join(tempJWKDir, "issuer-keys")) // Ensure we load up the default configs. dirname, err := os.MkdirTemp("", "tmpDir") @@ -218,8 +218,8 @@ func TestHandleWebUIAuth(t *testing.T) { }) tmpDir := t.TempDir() - issuerFile := filepath.Join(tmpDir, "issuer.key") - viper.Set(param.IssuerKey.GetName(), issuerFile) + issuerDirectory := filepath.Join(tmpDir, "issuer-keys") + viper.Set(param.IssuerKeysDirectory.GetName(), issuerDirectory) viper.Set(param.Server_ExternalWebUrl.GetName(), "https://example.com") _, err := config.GetIssuerPrivateJWK()