diff --git a/api/BUILD.bazel b/api/BUILD.bazel index 5d1e621cbaa0..38cd3a926d73 100644 --- a/api/BUILD.bazel +++ b/api/BUILD.bazel @@ -1,11 +1,24 @@ -load("@prysm//tools/go:def.bzl", "go_library") +load("@prysm//tools/go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ "constants.go", "headers.go", + "jwt.go", ], importpath = "github.com/prysmaticlabs/prysm/v5/api", visibility = ["//visibility:public"], + deps = [ + "//crypto/rand:go_default_library", + "@com_github_ethereum_go_ethereum//common/hexutil:go_default_library", + "@com_github_pkg_errors//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["jwt_test.go"], + embed = [":go_default_library"], + deps = ["//testing/require:go_default_library"], ) diff --git a/api/constants.go b/api/constants.go index 0b981f18abeb..4f8fdcd32f9c 100644 --- a/api/constants.go +++ b/api/constants.go @@ -4,4 +4,6 @@ const ( WebUrlPrefix = "/v2/validator/" WebApiUrlPrefix = "/api/v2/validator/" KeymanagerApiPrefix = "/eth/v1" + + AuthTokenFileName = "auth-token" ) diff --git a/api/jwt.go b/api/jwt.go new file mode 100644 index 000000000000..d123aa5a8154 --- /dev/null +++ b/api/jwt.go @@ -0,0 +1,32 @@ +package api + +import ( + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/pkg/errors" + "github.com/prysmaticlabs/prysm/v5/crypto/rand" +) + +// GenerateRandomHexString generates a random hex string that follows the standards for jwt token +// used for beacon node -> execution client +// used for web client -> validator client +func GenerateRandomHexString() (string, error) { + secret := make([]byte, 32) + randGen := rand.NewGenerator() + n, err := randGen.Read(secret) + if err != nil { + return "", err + } else if n != 32 { + return "", errors.New("rand: unexpected length") + } + return hexutil.Encode(secret), nil +} + +// ValidateAuthToken validating auth token for web +func ValidateAuthToken(token string) error { + b, err := hexutil.Decode(token) + // token should be hex-encoded and at least 256 bits + if err != nil || len(b) < 32 { + return errors.New("invalid auth token: token should be hex-encoded and at least 256 bits") + } + return nil +} diff --git a/api/jwt_test.go b/api/jwt_test.go new file mode 100644 index 000000000000..9296501cfccf --- /dev/null +++ b/api/jwt_test.go @@ -0,0 +1,13 @@ +package api + +import ( + "testing" + + "github.com/prysmaticlabs/prysm/v5/testing/require" +) + +func TestGenerateRandomHexString(t *testing.T) { + token, err := GenerateRandomHexString() + require.NoError(t, err) + require.NoError(t, ValidateAuthToken(token)) +} diff --git a/cmd/beacon-chain/jwt/BUILD.bazel b/cmd/beacon-chain/jwt/BUILD.bazel index 19ba95460629..a23f2f8c3880 100644 --- a/cmd/beacon-chain/jwt/BUILD.bazel +++ b/cmd/beacon-chain/jwt/BUILD.bazel @@ -6,10 +6,9 @@ go_library( importpath = "github.com/prysmaticlabs/prysm/v5/cmd/beacon-chain/jwt", visibility = ["//visibility:public"], deps = [ + "//api:go_default_library", "//cmd:go_default_library", - "//crypto/rand:go_default_library", "//io/file:go_default_library", - "@com_github_ethereum_go_ethereum//common/hexutil:go_default_library", "@com_github_sirupsen_logrus//:go_default_library", "@com_github_urfave_cli_v2//:go_default_library", ], diff --git a/cmd/beacon-chain/jwt/jwt.go b/cmd/beacon-chain/jwt/jwt.go index 8fdea40391f8..d93af9c2cb38 100644 --- a/cmd/beacon-chain/jwt/jwt.go +++ b/cmd/beacon-chain/jwt/jwt.go @@ -1,12 +1,10 @@ package jwt import ( - "errors" "path/filepath" - "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/prysmaticlabs/prysm/v5/api" "github.com/prysmaticlabs/prysm/v5/cmd" - "github.com/prysmaticlabs/prysm/v5/crypto/rand" "github.com/prysmaticlabs/prysm/v5/io/file" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" @@ -52,7 +50,7 @@ func generateAuthSecretInFile(c *cli.Context) error { return err } } - secret, err := generateRandomHexString() + secret, err := api.GenerateRandomHexString() if err != nil { return err } @@ -62,15 +60,3 @@ func generateAuthSecretInFile(c *cli.Context) error { logrus.Infof("Successfully wrote JSON-RPC authentication secret to file %s", fileName) return nil } - -func generateRandomHexString() (string, error) { - secret := make([]byte, 32) - randGen := rand.NewGenerator() - n, err := randGen.Read(secret) - if err != nil { - return "", err - } else if n <= 0 { - return "", errors.New("rand: unexpected length") - } - return hexutil.Encode(secret), nil -} diff --git a/cmd/validator/flags/BUILD.bazel b/cmd/validator/flags/BUILD.bazel index 3c4d2b5cf35c..d83a718c4add 100644 --- a/cmd/validator/flags/BUILD.bazel +++ b/cmd/validator/flags/BUILD.bazel @@ -15,6 +15,7 @@ go_library( "//validator:__subpackages__", ], deps = [ + "//api:go_default_library", "//config/params:go_default_library", "//io/file:go_default_library", "@com_github_urfave_cli_v2//:go_default_library", diff --git a/cmd/validator/flags/flags.go b/cmd/validator/flags/flags.go index 554d5eab8803..037ffaaf95d2 100644 --- a/cmd/validator/flags/flags.go +++ b/cmd/validator/flags/flags.go @@ -8,6 +8,7 @@ import ( "runtime" "time" + "github.com/prysmaticlabs/prysm/v5/api" "github.com/prysmaticlabs/prysm/v5/config/params" "github.com/prysmaticlabs/prysm/v5/io/file" "github.com/urfave/cli/v2" @@ -133,6 +134,15 @@ var ( Usage: "Port used to listening and respond metrics for Prometheus.", Value: 8081, } + + // AuthTokenPathFlag defines the path to the auth token used to secure the validator api. + AuthTokenPathFlag = &cli.StringFlag{ + Name: "keymanager-token-file", + Usage: "Path to auth token file used for validator apis.", + Value: filepath.Join(filepath.Join(DefaultValidatorDir(), WalletDefaultDirName), api.AuthTokenFileName), + Aliases: []string{"validator-api-bearer-file"}, + } + // WalletDirFlag defines the path to a wallet directory for Prysm accounts. WalletDirFlag = &cli.StringFlag{ Name: "wallet-dir", diff --git a/cmd/validator/main.go b/cmd/validator/main.go index 5692f9ec64f5..4251a720f30c 100644 --- a/cmd/validator/main.go +++ b/cmd/validator/main.go @@ -75,6 +75,7 @@ var appFlags = []cli.Flag{ flags.EnableWebFlag, flags.GraffitiFileFlag, flags.EnableDistributed, + flags.AuthTokenPathFlag, // Consensys' Web3Signer flags flags.Web3SignerURLFlag, flags.Web3SignerPublicValidatorKeysFlag, diff --git a/cmd/validator/usage.go b/cmd/validator/usage.go index ccc76d2617b9..900af603f12c 100644 --- a/cmd/validator/usage.go +++ b/cmd/validator/usage.go @@ -123,6 +123,7 @@ var appHelpFlagGroups = []flagGroup{ flags.BuilderGasLimitFlag, flags.ValidatorsRegistrationBatchSizeFlag, flags.EnableDistributed, + flags.AuthTokenPathFlag, }, }, { diff --git a/cmd/validator/web/BUILD.bazel b/cmd/validator/web/BUILD.bazel index bc99fe1538c5..f10c97b3f6c4 100644 --- a/cmd/validator/web/BUILD.bazel +++ b/cmd/validator/web/BUILD.bazel @@ -9,6 +9,7 @@ go_library( importpath = "github.com/prysmaticlabs/prysm/v5/cmd/validator/web", visibility = ["//visibility:public"], deps = [ + "//api:go_default_library", "//cmd:go_default_library", "//cmd/validator/flags:go_default_library", "//config/features:go_default_library", diff --git a/cmd/validator/web/web.go b/cmd/validator/web/web.go index f36544c82cfa..34a4edb22ace 100644 --- a/cmd/validator/web/web.go +++ b/cmd/validator/web/web.go @@ -2,7 +2,9 @@ package web import ( "fmt" + "path/filepath" + "github.com/prysmaticlabs/prysm/v5/api" "github.com/prysmaticlabs/prysm/v5/cmd" "github.com/prysmaticlabs/prysm/v5/cmd/validator/flags" "github.com/prysmaticlabs/prysm/v5/config/features" @@ -24,6 +26,7 @@ var Commands = &cli.Command{ flags.WalletDirFlag, flags.GRPCGatewayHost, flags.GRPCGatewayPort, + flags.AuthTokenPathFlag, cmd.AcceptTosFlag, }), Before: func(cliCtx *cli.Context) error { @@ -43,7 +46,12 @@ var Commands = &cli.Command{ gatewayHost := cliCtx.String(flags.GRPCGatewayHost.Name) gatewayPort := cliCtx.Int(flags.GRPCGatewayPort.Name) validatorWebAddr := fmt.Sprintf("%s:%d", gatewayHost, gatewayPort) - if err := rpc.CreateAuthToken(walletDirPath, validatorWebAddr); err != nil { + authTokenPath := filepath.Join(walletDirPath, api.AuthTokenFileName) + tempAuthTokenPath := cliCtx.String(flags.AuthTokenPathFlag.Name) + if tempAuthTokenPath != "" { + authTokenPath = tempAuthTokenPath + } + if err := rpc.CreateAuthToken(authTokenPath, validatorWebAddr); err != nil { log.WithError(err).Fatal("Could not create web auth token") } return nil diff --git a/validator/node/node.go b/validator/node/node.go index 67d92e32bf63..ea2509dc0f07 100644 --- a/validator/node/node.go +++ b/validator/node/node.go @@ -639,6 +639,17 @@ func (c *ValidatorClient) registerRPCService(router *mux.Router) error { walletDir := c.cliCtx.String(flags.WalletDirFlag.Name) grpcHeaders := c.cliCtx.String(flags.GrpcHeadersFlag.Name) clientCert := c.cliCtx.String(flags.CertFlag.Name) + + authTokenPath := c.cliCtx.String(flags.AuthTokenPathFlag.Name) + // if no auth token path flag was passed try to set a default value + if authTokenPath == "" { + authTokenPath = flags.AuthTokenPathFlag.Value + // if a wallet dir is passed without an auth token then override the default with the wallet dir + if walletDir != "" { + authTokenPath = filepath.Join(walletDir, api.AuthTokenFileName) + } + } + server := rpc.NewServer(c.cliCtx.Context, &rpc.Config{ ValDB: c.db, Host: rpcHost, @@ -648,6 +659,7 @@ func (c *ValidatorClient) registerRPCService(router *mux.Router) error { SyncChecker: vs, GenesisFetcher: vs, NodeGatewayEndpoint: nodeGatewayEndpoint, + AuthTokenPath: authTokenPath, WalletDir: walletDir, Wallet: c.wallet, ValidatorGatewayHost: validatorGatewayHost, diff --git a/validator/rpc/BUILD.bazel b/validator/rpc/BUILD.bazel index 1c7742f68eed..2e270bdeec96 100644 --- a/validator/rpc/BUILD.bazel +++ b/validator/rpc/BUILD.bazel @@ -38,7 +38,6 @@ go_library( "//consensus-types/primitives:go_default_library", "//consensus-types/validator:go_default_library", "//crypto/bls:go_default_library", - "//crypto/rand:go_default_library", "//encoding/bytesutil:go_default_library", "//io/file:go_default_library", "//io/logs:go_default_library", @@ -148,6 +147,7 @@ go_test( "@com_github_gorilla_mux//:go_default_library", "@com_github_grpc_ecosystem_grpc_gateway_v2//runtime:go_default_library", "@com_github_pkg_errors//:go_default_library", + "@com_github_sirupsen_logrus//hooks/test:go_default_library", "@com_github_tyler_smith_go_bip39//:go_default_library", "@com_github_wealdtech_go_eth2_wallet_encryptor_keystorev4//:go_default_library", "@org_golang_google_grpc//:go_default_library", diff --git a/validator/rpc/auth_token.go b/validator/rpc/auth_token.go index 72a6fa571d2f..a87dbfb596df 100644 --- a/validator/rpc/auth_token.go +++ b/validator/rpc/auth_token.go @@ -15,32 +15,24 @@ import ( "github.com/fsnotify/fsnotify" "github.com/golang-jwt/jwt/v4" "github.com/pkg/errors" - "github.com/prysmaticlabs/prysm/v5/crypto/rand" + "github.com/prysmaticlabs/prysm/v5/api" + "github.com/prysmaticlabs/prysm/v5/encoding/bytesutil" "github.com/prysmaticlabs/prysm/v5/io/file" ) -const ( - AuthTokenFileName = "auth-token" -) - // CreateAuthToken generates a new jwt key, token and writes them // to a file in the specified directory. Also, it logs out a prepared URL // for the user to navigate to and authenticate with the Prysm web interface. -func CreateAuthToken(walletDirPath, validatorWebAddr string) error { - jwtKey, err := createRandomJWTSecret() - if err != nil { - return err - } - token, err := createTokenString(jwtKey) +func CreateAuthToken(authPath, validatorWebAddr string) error { + token, err := api.GenerateRandomHexString() if err != nil { return err } - authTokenPath := filepath.Join(walletDirPath, AuthTokenFileName) - log.Infof("Generating auth token and saving it to %s", authTokenPath) - if err := saveAuthToken(walletDirPath, jwtKey, token); err != nil { + log.Infof("Generating auth token and saving it to %s", authPath) + if err := saveAuthToken(authPath, token); err != nil { return err } - logValidatorWebAuth(validatorWebAddr, token, authTokenPath) + logValidatorWebAuth(validatorWebAddr, token, authPath) return nil } @@ -49,18 +41,18 @@ func CreateAuthToken(walletDirPath, validatorWebAddr string) error { // user via stdout and the validator client should then attempt to open the default // browser. The web interface authenticates by looking for this token in the query parameters // of the URL. This token is then used as the bearer token for jwt auth. -func (s *Server) initializeAuthToken(walletDir string) (string, error) { - authTokenFile := filepath.Join(walletDir, AuthTokenFileName) - exists, err := file.Exists(authTokenFile, file.Regular) +func (s *Server) initializeAuthToken() error { + if s.authTokenPath == "" { + return errors.New("auth token path is empty") + } + exists, err := file.Exists(s.authTokenPath, file.Regular) if err != nil { - return "", errors.Wrapf(err, "could not check if file exists: %s", authTokenFile) + return errors.Wrapf(err, "could not check if file %s exists", s.authTokenPath) } - if exists { - // #nosec G304 - f, err := os.Open(authTokenFile) + f, err := os.Open(filepath.Clean(s.authTokenPath)) if err != nil { - return "", err + return err } defer func() { if err := f.Close(); err != nil { @@ -69,24 +61,18 @@ func (s *Server) initializeAuthToken(walletDir string) (string, error) { }() secret, token, err := readAuthTokenFile(f) if err != nil { - return "", err + return err } s.jwtSecret = secret - return token, nil - } - jwtKey, err := createRandomJWTSecret() - if err != nil { - return "", err + s.authToken = token + return nil } - s.jwtSecret = jwtKey - token, err := createTokenString(s.jwtSecret) + token, err := api.GenerateRandomHexString() if err != nil { - return "", err - } - if err := saveAuthToken(walletDir, jwtKey, token); err != nil { - return "", err + return err } - return token, nil + s.authToken = token + return saveAuthToken(s.authTokenPath, token) } func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenPath string) { @@ -106,16 +92,20 @@ func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenP } for { select { - case <-watcher.Events: + case event := <-watcher.Events: + if event.Op.String() == "REMOVE" { + log.Error("Auth Token was removed! Restart the validator client to regenerate a token") + s.authToken = "" + continue + } // If a file was modified, we attempt to read that file // and parse it into our accounts store. - token, err := s.initializeAuthToken(s.walletDir) - if err != nil { + if err := s.initializeAuthToken(); err != nil { log.WithError(err).Errorf("Could not watch for file changes for: %s", authTokenPath) continue } validatorWebAddr := fmt.Sprintf("%s:%d", s.validatorGatewayHost, s.validatorGatewayPort) - logValidatorWebAuth(validatorWebAddr, token, authTokenPath) + logValidatorWebAuth(validatorWebAddr, s.authToken, authTokenPath) case err := <-watcher.Errors: log.WithError(err).Errorf("Could not watch for file changes for: %s", authTokenPath) case <-ctx.Done(): @@ -124,7 +114,7 @@ func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenP } } -func logValidatorWebAuth(validatorWebAddr, token string, tokenPath string) { +func logValidatorWebAuth(validatorWebAddr, token, tokenPath string) { webAuthURLTemplate := "http://%s/initialize?token=%s" webAuthURL := fmt.Sprintf( webAuthURLTemplate, @@ -136,18 +126,11 @@ func logValidatorWebAuth(validatorWebAddr, token string, tokenPath string) { "the Prysm web interface", ) log.Info(webAuthURL) - log.Infof("Validator CLient JWT for RPC and REST authentication set at:%s", tokenPath) + log.Infof("Validator Client auth token for gRPC and REST authentication set at %s", tokenPath) } -func saveAuthToken(walletDirPath string, jwtKey []byte, token string) error { - hashFilePath := filepath.Join(walletDirPath, AuthTokenFileName) +func saveAuthToken(tokenPath string, token string) error { bytesBuf := new(bytes.Buffer) - if _, err := bytesBuf.WriteString(fmt.Sprintf("%x", jwtKey)); err != nil { - return err - } - if _, err := bytesBuf.WriteString("\n"); err != nil { - return err - } if _, err := bytesBuf.WriteString(token); err != nil { return err } @@ -155,34 +138,61 @@ func saveAuthToken(walletDirPath string, jwtKey []byte, token string) error { return err } - if err := file.MkdirAll(walletDirPath); err != nil { - return errors.Wrapf(err, "could not create directory %s", walletDirPath) + if err := file.MkdirAll(filepath.Dir(tokenPath)); err != nil { + return errors.Wrapf(err, "could not create directory %s", filepath.Dir(tokenPath)) } - - if err := file.WriteFile(hashFilePath, bytesBuf.Bytes()); err != nil { - return errors.Wrapf(err, "could not write to file %s", hashFilePath) + if err := file.WriteFile(tokenPath, bytesBuf.Bytes()); err != nil { + return errors.Wrapf(err, "could not write to file %s", tokenPath) } return nil } -func readAuthTokenFile(r io.Reader) (secret []byte, token string, err error) { - br := bufio.NewReader(r) - var jwtKeyHex string - jwtKeyHex, err = br.ReadString('\n') - if err != nil { - return - } - secret, err = hex.DecodeString(strings.TrimSpace(jwtKeyHex)) - if err != nil { - return +func readAuthTokenFile(r io.Reader) ([]byte, string, error) { + scanner := bufio.NewScanner(r) + var lines []string + var secret []byte + var token string + // Scan the file and collect lines, excluding empty lines + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } } - tokenBytes, _, err := br.ReadLine() - if err != nil { - return + + // Check for scanning errors + if err := scanner.Err(); err != nil { + return nil, "", err } - token = strings.TrimSpace(string(tokenBytes)) - return + + // Process based on the number of lines, excluding empty ones + switch len(lines) { + case 1: + // If there is only one line, interpret it as the token + token = strings.TrimSpace(lines[0]) + case 2: + // TODO: Deprecate after a few releases + // For legacy files + // If there are two lines, the first is the jwt key and the second is the token + jwtKeyHex := strings.TrimSpace(lines[0]) + s, err := hex.DecodeString(jwtKeyHex) + if err != nil { + return nil, "", errors.Wrapf(err, "could not decode JWT secret") + } + secret = bytesutil.SafeCopyBytes(s) + token = strings.TrimSpace(lines[1]) + log.Warn("Auth token is a legacy file and should be regenerated.") + default: + return nil, "", errors.New("Auth token file format has multiple lines, please update the auth token to a single line that is a 256 bit hex string") + } + if err := api.ValidateAuthToken(token); err != nil { + log.WithError(err).Warn("Auth token does not follow our standards and should be regenerated either \n" + + "1. by removing the current token file and restarting \n" + + "2. using the `validator web generate-auth-token` command. \n" + + "Tokens can be generated through the `validator web generate-auth-token` command") + } + return secret, token, nil } // Creates a JWT token string using the JWT key. @@ -195,16 +205,3 @@ func createTokenString(jwtKey []byte) (string, error) { } return tokenString, nil } - -func createRandomJWTSecret() ([]byte, error) { - r := rand.NewGenerator() - jwtKey := make([]byte, 32) - n, err := r.Read(jwtKey) - if err != nil { - return nil, err - } - if n != len(jwtKey) { - return nil, errors.New("could not create appropriately sized random JWT secret") - } - return jwtKey, nil -} diff --git a/validator/rpc/auth_token_test.go b/validator/rpc/auth_token_test.go index efddde467a8e..35f308b38568 100644 --- a/validator/rpc/auth_token_test.go +++ b/validator/rpc/auth_token_test.go @@ -1,16 +1,22 @@ package rpc import ( + "bufio" "bytes" "context" "encoding/hex" "os" "path/filepath" + "strings" "testing" "time" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/golang-jwt/jwt/v4" + "github.com/prysmaticlabs/prysm/v5/api" + "github.com/prysmaticlabs/prysm/v5/io/file" "github.com/prysmaticlabs/prysm/v5/testing/require" + logTest "github.com/sirupsen/logrus/hooks/test" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -24,11 +30,14 @@ func setupWalletDir(t testing.TB) string { func TestServer_AuthenticateUsingExistingToken(t *testing.T) { // Initializing for the first time, there is no auth token file in // the wallet directory, so we generate a jwt token and secret from scratch. - srv := &Server{} walletDir := setupWalletDir(t) - token, err := srv.initializeAuthToken(walletDir) + authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName) + srv := &Server{ + authTokenPath: authTokenPath, + } + + err := srv.initializeAuthToken() require.NoError(t, err) - require.Equal(t, true, len(srv.jwtSecret) > 0) unaryInfo := &grpc.UnaryServerInfo{ FullMethod: "Proto.CreateWallet", @@ -37,78 +46,173 @@ func TestServer_AuthenticateUsingExistingToken(t *testing.T) { return nil, nil } ctxMD := map[string][]string{ - "authorization": {"Bearer " + token}, + "authorization": {"Bearer " + srv.authToken}, } ctx := context.Background() ctx = metadata.NewIncomingContext(ctx, ctxMD) - _, err = srv.JWTInterceptor()(ctx, "xyz", unaryInfo, unaryHandler) + _, err = srv.AuthTokenInterceptor()(ctx, "xyz", unaryInfo, unaryHandler) require.NoError(t, err) // Next up, we make the same request but reinitialize the server and we should still // pass with the same auth token. - srv = &Server{} - _, err = srv.initializeAuthToken(walletDir) + srv = &Server{ + authTokenPath: authTokenPath, + } + err = srv.initializeAuthToken() require.NoError(t, err) - require.Equal(t, true, len(srv.jwtSecret) > 0) - _, err = srv.JWTInterceptor()(ctx, "xyz", unaryInfo, unaryHandler) + _, err = srv.AuthTokenInterceptor()(ctx, "xyz", unaryInfo, unaryHandler) require.NoError(t, err) } -func TestServer_RefreshJWTSecretOnFileChange(t *testing.T) { +func TestServer_RefreshAuthTokenOnFileChange(t *testing.T) { // Initializing for the first time, there is no auth token file in // the wallet directory, so we generate a jwt token and secret from scratch. - srv := &Server{} walletDir := setupWalletDir(t) - _, err := srv.initializeAuthToken(walletDir) - require.NoError(t, err) - currentSecret := srv.jwtSecret - require.Equal(t, true, len(currentSecret) > 0) + authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName) + srv := &Server{ + authTokenPath: authTokenPath, + } - authTokenPath := filepath.Join(walletDir, AuthTokenFileName) + err := srv.initializeAuthToken() + require.NoError(t, err) + currentToken := srv.authToken ctx, cancel := context.WithCancel(context.Background()) defer cancel() - go srv.refreshAuthTokenFromFileChanges(ctx, authTokenPath) + go srv.refreshAuthTokenFromFileChanges(ctx, srv.authTokenPath) // Wait for service to be ready. time.Sleep(time.Millisecond * 250) // Update the auth token file with a new secret. - require.NoError(t, CreateAuthToken(walletDir, "localhost:7500")) + require.NoError(t, CreateAuthToken(srv.authTokenPath, "localhost:7500")) // The service should have picked up the file change and set the jwt secret to the new one. time.Sleep(time.Millisecond * 500) - newSecret := srv.jwtSecret - require.Equal(t, true, len(newSecret) > 0) - require.Equal(t, true, !bytes.Equal(currentSecret, newSecret)) - err = os.Remove(AuthTokenFileName) + newToken := srv.authToken + require.Equal(t, true, currentToken != newToken) + err = os.Remove(srv.authTokenPath) require.NoError(t, err) } +// TODO: remove this test when legacy files are removed +func TestServer_LegacyTokensStillWork(t *testing.T) { + hook := logTest.NewGlobal() + // Initializing for the first time, there is no auth token file in + // the wallet directory, so we generate a jwt token and secret from scratch. + walletDir := setupWalletDir(t) + authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName) + + bytesBuf := new(bytes.Buffer) + _, err := bytesBuf.WriteString("b5bbbaf533b625a93741978857f13d7adeca58445a1fb00ecf3373420b92776c") + require.NoError(t, err) + _, err = bytesBuf.WriteString("\n") + require.NoError(t, err) + _, err = bytesBuf.WriteString("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg") + require.NoError(t, err) + _, err = bytesBuf.WriteString("\n") + require.NoError(t, err) + err = file.MkdirAll(walletDir) + require.NoError(t, err) + + err = file.WriteFile(authTokenPath, bytesBuf.Bytes()) + require.NoError(t, err) + + srv := &Server{ + authTokenPath: authTokenPath, + } + + err = srv.initializeAuthToken() + require.NoError(t, err) + + require.Equal(t, hexutil.Encode(srv.jwtSecret), "0xb5bbbaf533b625a93741978857f13d7adeca58445a1fb00ecf3373420b92776c") + require.Equal(t, srv.authToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg") + + f, err := os.Open(filepath.Clean(srv.authTokenPath)) + require.NoError(t, err) + + scanner := bufio.NewScanner(f) + var lines []string + + // Scan the file and collect lines, excluding empty lines + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } + } + require.Equal(t, len(lines), 2) + require.LogsContain(t, hook, "Auth token does not follow our standards and should be regenerated") + // Check for scanning errors + err = scanner.Err() + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + err = os.Remove(srv.authTokenPath) + require.NoError(t, err) +} + +// TODO: remove this test when legacy files are removed +func TestServer_LegacyTokensBadSecret(t *testing.T) { + // Initializing for the first time, there is no auth token file in + // the wallet directory, so we generate a jwt token and secret from scratch. + walletDir := setupWalletDir(t) + authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName) + + bytesBuf := new(bytes.Buffer) + _, err := bytesBuf.WriteString("----------------") + require.NoError(t, err) + _, err = bytesBuf.WriteString("\n") + require.NoError(t, err) + _, err = bytesBuf.WriteString("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg") + require.NoError(t, err) + _, err = bytesBuf.WriteString("\n") + require.NoError(t, err) + err = file.MkdirAll(walletDir) + require.NoError(t, err) + + err = file.WriteFile(authTokenPath, bytesBuf.Bytes()) + require.NoError(t, err) + + srv := &Server{ + authTokenPath: authTokenPath, + } + + err = srv.initializeAuthToken() + require.ErrorContains(t, "could not decode JWT secret", err) +} + func Test_initializeAuthToken(t *testing.T) { // Initializing for the first time, there is no auth token file in // the wallet directory, so we generate a jwt token and secret from scratch. - srv := &Server{} walletDir := setupWalletDir(t) - token, err := srv.initializeAuthToken(walletDir) + authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName) + srv := &Server{ + authTokenPath: authTokenPath, + } + err := srv.initializeAuthToken() require.NoError(t, err) - require.Equal(t, true, len(srv.jwtSecret) > 0) // Initializing second time, we generate something from the initial file. - srv2 := &Server{} - token2, err := srv2.initializeAuthToken(walletDir) + srv2 := &Server{ + authTokenPath: authTokenPath, + } + err = srv2.initializeAuthToken() require.NoError(t, err) - require.Equal(t, true, bytes.Equal(srv.jwtSecret, srv2.jwtSecret)) - require.Equal(t, token, token2) + require.Equal(t, srv.authToken, srv2.authToken) // Deleting the auth token and re-initializing means we create a jwt token // and secret from scratch again. - srv3 := &Server{} walletDir = setupWalletDir(t) - token3, err := srv3.initializeAuthToken(walletDir) + authTokenPath = filepath.Join(walletDir, api.AuthTokenFileName) + srv3 := &Server{ + authTokenPath: authTokenPath, + } + + err = srv3.initializeAuthToken() require.NoError(t, err) - require.Equal(t, true, len(srv.jwtSecret) > 0) - require.NotEqual(t, token, token3) + require.NotEqual(t, srv.authToken, srv3.authToken) } // "createTokenString" now uses jwt.RegisteredClaims instead of jwt.StandardClaims (deprecated), diff --git a/validator/rpc/handlers_auth.go b/validator/rpc/handlers_auth.go index 4222e10ba76a..24c29491945a 100644 --- a/validator/rpc/handlers_auth.go +++ b/validator/rpc/handlers_auth.go @@ -2,7 +2,6 @@ package rpc import ( "net/http" - "path/filepath" "github.com/pkg/errors" "github.com/prysmaticlabs/prysm/v5/io/file" @@ -20,8 +19,7 @@ func (s *Server) Initialize(w http.ResponseWriter, r *http.Request) { httputil.HandleError(w, errors.Wrap(err, "Could not check if wallet exists").Error(), http.StatusInternalServerError) return } - authTokenPath := filepath.Join(s.walletDir, AuthTokenFileName) - exists, err := file.Exists(authTokenPath, file.Regular) + exists, err := file.Exists(s.authTokenPath, file.Regular) if err != nil { httputil.HandleError(w, errors.Wrap(err, "Could not check if auth token exists").Error(), http.StatusInternalServerError) return diff --git a/validator/rpc/handlers_auth_test.go b/validator/rpc/handlers_auth_test.go index b17cd0cae80d..473aed41eb45 100644 --- a/validator/rpc/handlers_auth_test.go +++ b/validator/rpc/handlers_auth_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/prysmaticlabs/prysm/v5/api" "github.com/prysmaticlabs/prysm/v5/testing/require" "github.com/prysmaticlabs/prysm/v5/validator/accounts" "github.com/prysmaticlabs/prysm/v5/validator/keymanager" @@ -19,7 +20,7 @@ func TestInitialize(t *testing.T) { localWalletDir := setupWalletDir(t) // Step 2: Optionally create a temporary 'auth-token' file - authTokenPath := filepath.Join(localWalletDir, AuthTokenFileName) + authTokenPath := filepath.Join(localWalletDir, api.AuthTokenFileName) _, err := os.Create(authTokenPath) require.NoError(t, err) @@ -34,7 +35,7 @@ func TestInitialize(t *testing.T) { require.NoError(t, err) _, err = acc.WalletCreate(context.Background()) require.NoError(t, err) - server := &Server{walletDir: localWalletDir} + server := &Server{walletDir: localWalletDir, authTokenPath: authTokenPath} // Step 4: Create an HTTP request and response recorder req := httptest.NewRequest(http.MethodGet, "/initialize", nil) @@ -43,6 +44,8 @@ func TestInitialize(t *testing.T) { // Step 5: Call the Initialize function server.Initialize(w, req) + require.Equal(t, w.Code, http.StatusOK) + // Step 6: Assert expectations result := w.Result() defer func() { diff --git a/validator/rpc/intercepter.go b/validator/rpc/intercepter.go index a49db2c79cb3..6dedcdaef469 100644 --- a/validator/rpc/intercepter.go +++ b/validator/rpc/intercepter.go @@ -2,11 +2,9 @@ package rpc import ( "context" - "fmt" "net/http" "strings" - "github.com/golang-jwt/jwt/v4" "github.com/prysmaticlabs/prysm/v5/api" "github.com/sirupsen/logrus" "google.golang.org/grpc" @@ -15,8 +13,8 @@ import ( "google.golang.org/grpc/status" ) -// JWTInterceptor is a gRPC unary interceptor to authorize incoming requests. -func (s *Server) JWTInterceptor() grpc.UnaryServerInterceptor { +// AuthTokenInterceptor is a gRPC unary interceptor to authorize incoming requests. +func (s *Server) AuthTokenInterceptor() grpc.UnaryServerInterceptor { return func( ctx context.Context, req interface{}, @@ -35,8 +33,8 @@ func (s *Server) JWTInterceptor() grpc.UnaryServerInterceptor { } } -// JwtHttpInterceptor is an HTTP handler to authorize a route. -func (s *Server) JwtHttpInterceptor(next http.Handler) http.Handler { +// AuthTokenHandler is an HTTP handler to authorize a route. +func (s *Server) AuthTokenHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // if it's not initialize or has a web prefix if strings.Contains(r.URL.Path, api.WebApiUrlPrefix) || strings.Contains(r.URL.Path, api.KeymanagerApiPrefix) { @@ -53,9 +51,8 @@ func (s *Server) JwtHttpInterceptor(next http.Handler) http.Handler { } token := tokenParts[1] - _, err := jwt.Parse(token, s.validateJWT) - if err != nil { - http.Error(w, fmt.Errorf("forbidden: could not parse JWT token: %v", err).Error(), http.StatusForbidden) + if strings.TrimSpace(token) != s.authToken || strings.TrimSpace(s.authToken) == "" { + http.Error(w, "Forbidden: token value is invalid", http.StatusForbidden) return } } @@ -78,16 +75,8 @@ func (s *Server) authorize(ctx context.Context) error { return status.Error(codes.Unauthenticated, "Invalid auth header, needs Bearer {token}") } token := strings.Split(authHeader[0], "Bearer ")[1] - _, err := jwt.Parse(token, s.validateJWT) - if err != nil { - return status.Errorf(codes.Unauthenticated, "Could not parse JWT token: %v", err) + if strings.TrimSpace(token) != s.authToken || strings.TrimSpace(s.authToken) == "" { + return status.Errorf(codes.Unauthenticated, "Forbidden: token value is invalid") } return nil } - -func (s *Server) validateJWT(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected JWT signing method: %v", token.Header["alg"]) - } - return s.jwtSecret, nil -} diff --git a/validator/rpc/intercepter_test.go b/validator/rpc/intercepter_test.go index e4fcbbdfb314..94a0461da476 100644 --- a/validator/rpc/intercepter_test.go +++ b/validator/rpc/intercepter_test.go @@ -6,18 +6,18 @@ import ( "net/http/httptest" "testing" - "github.com/golang-jwt/jwt/v4" "github.com/prysmaticlabs/prysm/v5/api" "github.com/prysmaticlabs/prysm/v5/testing/require" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) -func TestServer_JWTInterceptor_Verify(t *testing.T) { +func TestServer_AuthTokenInterceptor_Verify(t *testing.T) { + token := "cool-token" s := Server{ - jwtSecret: []byte("testKey"), + authToken: token, } - interceptor := s.JWTInterceptor() + interceptor := s.AuthTokenInterceptor() unaryInfo := &grpc.UnaryServerInfo{ FullMethod: "Proto.CreateWallet", @@ -25,22 +25,20 @@ func TestServer_JWTInterceptor_Verify(t *testing.T) { unaryHandler := func(ctx context.Context, req interface{}) (interface{}, error) { return nil, nil } - token, err := createTokenString(s.jwtSecret) - require.NoError(t, err) ctxMD := map[string][]string{ "authorization": {"Bearer " + token}, } ctx := context.Background() ctx = metadata.NewIncomingContext(ctx, ctxMD) - _, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler) + _, err := interceptor(ctx, "xyz", unaryInfo, unaryHandler) require.NoError(t, err) } -func TestServer_JWTInterceptor_BadToken(t *testing.T) { +func TestServer_AuthTokenInterceptor_BadToken(t *testing.T) { s := Server{ - jwtSecret: []byte("testKey"), + authToken: "cool-token", } - interceptor := s.JWTInterceptor() + interceptor := s.AuthTokenInterceptor() unaryInfo := &grpc.UnaryServerInfo{ FullMethod: "Proto.CreateWallet", @@ -49,111 +47,65 @@ func TestServer_JWTInterceptor_BadToken(t *testing.T) { return nil, nil } - badServer := Server{ - jwtSecret: []byte("badTestKey"), - } - token, err := createTokenString(badServer.jwtSecret) - require.NoError(t, err) ctxMD := map[string][]string{ - "authorization": {"Bearer " + token}, + "authorization": {"Bearer bad-token"}, } ctx := context.Background() ctx = metadata.NewIncomingContext(ctx, ctxMD) - _, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler) - require.ErrorContains(t, "signature is invalid", err) -} - -func TestServer_JWTInterceptor_InvalidSigningType(t *testing.T) { - ss := &Server{jwtSecret: make([]byte, 32)} - // Use a different signing type than the expected, HMAC. - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{}) - _, err := ss.validateJWT(token) - require.ErrorContains(t, "unexpected JWT signing method", err) + _, err := interceptor(ctx, "xyz", unaryInfo, unaryHandler) + require.ErrorContains(t, "token value is invalid", err) } -func TestServer_JwtHttpInterceptor(t *testing.T) { - jwtKey, err := createRandomJWTSecret() - require.NoError(t, err) +func TestServer_AuthTokenHandler(t *testing.T) { + token := "cool-token" - s := &Server{jwtSecret: jwtKey} - testHandler := s.JwtHttpInterceptor(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s := &Server{authToken: token} + testHandler := s.AuthTokenHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Your test handler logic here w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("Test Response")) require.NoError(t, err) })) - t.Run("no jwt was sent", func(t *testing.T) { + t.Run("no auth token was sent", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) + req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody) require.NoError(t, err) testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code) }) - t.Run("wrong jwt was sent", func(t *testing.T) { + t.Run("wrong auth token was sent", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) + req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody) require.NoError(t, err) req.Header.Set("Authorization", "Bearer YOUR_JWT_TOKEN") // Replace with a valid JWT token testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusForbidden, rr.Code) }) - t.Run("jwt was sent", func(t *testing.T) { + t.Run("good auth token was sent", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) - require.NoError(t, err) - token, err := createTokenString(jwtKey) + req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody) require.NoError(t, err) req.Header.Set("Authorization", "Bearer "+token) // Replace with a valid JWT token testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) }) - t.Run("wrong jwt format was sent", func(t *testing.T) { - rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) - require.NoError(t, err) - token, err := createTokenString(jwtKey) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer"+token) // no space was added // Replace with a valid JWT token - testHandler.ServeHTTP(rr, req) - require.Equal(t, http.StatusBadRequest, rr.Code) - }) - t.Run("wrong jwt no bearer format was sent", func(t *testing.T) { - rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) - require.NoError(t, err) - token, err := createTokenString(jwtKey) - require.NoError(t, err) - req.Header.Set("Authorization", token) // Replace with a valid JWT token - testHandler.ServeHTTP(rr, req) - require.Equal(t, http.StatusBadRequest, rr.Code) - }) - t.Run("broken jwt token format was sent", func(t *testing.T) { - rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil) - require.NoError(t, err) - token, err := createTokenString(jwtKey) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer "+token[0:2]+" "+token[2:]) // Replace with a valid JWT token - testHandler.ServeHTTP(rr, req) - require.Equal(t, http.StatusForbidden, rr.Code) - }) - t.Run("web endpoint needs jwt token", func(t *testing.T) { + t.Run("web endpoint needs auth token", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, "/api/v2/validator/beacon/status", nil) + req, err := http.NewRequest(http.MethodGet, "/api/v2/validator/beacon/status", http.NoBody) require.NoError(t, err) testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusUnauthorized, rr.Code) }) - t.Run("initialize does not need jwt", func(t *testing.T) { + t.Run("initialize does not need auth", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"initialize", nil) + req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"initialize", http.NoBody) require.NoError(t, err) testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) }) - t.Run("health does not need jwt", func(t *testing.T) { + t.Run("health does not need auth", func(t *testing.T) { rr := httptest.NewRecorder() - req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"health/logs", nil) + req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"health/logs", http.NoBody) require.NoError(t, err) testHandler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) diff --git a/validator/rpc/server.go b/validator/rpc/server.go index 10cdc6de69f5..a0737d485c64 100644 --- a/validator/rpc/server.go +++ b/validator/rpc/server.go @@ -47,6 +47,7 @@ type Config struct { CertFlag string KeyFlag string ValDB db.Database + AuthTokenPath string WalletDir string ValidatorService *client.ValidatorService SyncChecker client.SyncChecker @@ -87,6 +88,8 @@ type Server struct { validatorService *client.ValidatorService syncChecker client.SyncChecker genesisFetcher client.GenesisFetcher + authTokenPath string + authToken string walletDir string wallet *wallet.Wallet walletInitializedFeed *event.Feed @@ -123,6 +126,7 @@ func NewServer(ctx context.Context, cfg *Config) *Server { validatorService: cfg.ValidatorService, syncChecker: cfg.SyncChecker, genesisFetcher: cfg.GenesisFetcher, + authTokenPath: cfg.AuthTokenPath, walletDir: cfg.WalletDir, walletInitializedFeed: cfg.WalletInitializedFeed, walletInitialized: cfg.Wallet != nil, @@ -136,6 +140,19 @@ func NewServer(ctx context.Context, cfg *Config) *Server { beaconApiEndpoint: cfg.BeaconApiEndpoint, router: cfg.Router, } + + if server.authTokenPath == "" && server.walletDir != "" { + server.authTokenPath = filepath.Join(server.walletDir, api.AuthTokenFileName) + } + + if server.authTokenPath != "" { + if err := server.initializeAuthToken(); err != nil { + log.WithError(err).Error("Could not initialize web auth token") + } + validatorWebAddr := fmt.Sprintf("%s:%d", server.validatorGatewayHost, server.validatorGatewayPort) + logValidatorWebAuth(validatorWebAddr, server.authToken, server.authTokenPath) + go server.refreshAuthTokenFromFileChanges(server.ctx, server.authTokenPath) + } // immediately register routes to override any catchalls if err := server.InitializeRoutes(); err != nil { log.WithError(err).Fatal("Could not initialize routes") @@ -146,7 +163,7 @@ func NewServer(ctx context.Context, cfg *Config) *Server { // Start the gRPC server. func (s *Server) Start() { // Setup the gRPC server options and TLS configuration. - address := fmt.Sprintf("%s:%s", s.host, s.port) + address := net.JoinHostPort(s.host, s.port) lis, err := net.Listen("tcp", address) if err != nil { log.WithError(err).Errorf("Could not listen to port in Start() %s", address) @@ -163,7 +180,7 @@ func (s *Server) Start() { ), grpcprometheus.UnaryServerInterceptor, grpcopentracing.UnaryServerInterceptor(), - s.JWTInterceptor(), + s.AuthTokenInterceptor(), )), } grpcprometheus.EnableHandlingTimeHistogram() @@ -198,17 +215,6 @@ func (s *Server) Start() { }() log.WithField("address", address).Info("gRPC server listening on address") - if s.walletDir != "" { - token, err := s.initializeAuthToken(s.walletDir) - if err != nil { - log.WithError(err).Error("Could not initialize web auth token") - return - } - validatorWebAddr := fmt.Sprintf("%s:%d", s.validatorGatewayHost, s.validatorGatewayPort) - authTokenPath := filepath.Join(s.walletDir, AuthTokenFileName) - logValidatorWebAuth(validatorWebAddr, token, authTokenPath) - go s.refreshAuthTokenFromFileChanges(s.ctx, authTokenPath) - } } // InitializeRoutes initializes pure HTTP REST endpoints for the validator client. @@ -218,7 +224,7 @@ func (s *Server) InitializeRoutes() error { return errors.New("no router found on server") } // Adding Auth Interceptor for the routes below - s.router.Use(s.JwtHttpInterceptor) + s.router.Use(s.AuthTokenHandler) // Register all services, HandleFunc calls, etc. // ... s.router.HandleFunc("/eth/v1/keystores", s.ListKeystores).Methods(http.MethodGet)