diff --git a/go.mod b/go.mod index ecc3544869..8c862a0ba1 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( cosmossdk.io/x/evidence v0.1.0 cosmossdk.io/x/feegrant v0.1.0 cosmossdk.io/x/upgrade v0.1.1 + github.com/99designs/keyring v1.2.2 github.com/cometbft/cometbft v0.38.7-0.20240412124004-1f67e396cf45 github.com/cosmos/cosmos-db v1.0.3-0.20240408151834-e75f6e4b28d8 github.com/cosmos/cosmos-proto v1.0.0-beta.4 @@ -40,6 +41,8 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 + github.com/test-go/testify v1.1.4 + golang.org/x/crypto v0.21.0 google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de google.golang.org/grpc v1.63.2 google.golang.org/protobuf v1.33.0 @@ -57,7 +60,6 @@ require ( cosmossdk.io/x/tx v0.13.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect - github.com/99designs/keyring v1.2.2 // indirect github.com/DataDog/datadog-go v4.8.3+incompatible // indirect github.com/DataDog/zstd v1.5.5 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect @@ -225,7 +227,6 @@ require ( go.opentelemetry.io/otel/metric v1.22.0 // indirect go.opentelemetry.io/otel/trace v1.22.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect golang.org/x/mod v0.15.0 // indirect golang.org/x/net v0.23.0 // indirect diff --git a/x/e2ee/keyring/keyring.go b/x/e2ee/keyring/keyring.go new file mode 100644 index 0000000000..aac0e9de55 --- /dev/null +++ b/x/e2ee/keyring/keyring.go @@ -0,0 +1,202 @@ +package keyring + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/99designs/keyring" + "golang.org/x/crypto/bcrypt" + + errorsmod "cosmossdk.io/errors" + "github.com/cosmos/cosmos-sdk/client/input" + sdkkeyring "github.com/cosmos/cosmos-sdk/crypto/keyring" +) + +const ( + keyringFileDirName = "e2ee-keyring-file" + keyringTestDirName = "e2ee-keyring-test" + passKeyringPrefix = "e2ee-keyring-%s" + maxPassphraseEntryAttempts = 3 +) + +type Keyring interface { + Get(string) ([]byte, error) + Set(string, []byte) error +} + +func New( + appName, backend, rootDir string, userInput io.Reader, +) (Keyring, error) { + var ( + db keyring.Keyring + err error + ) + serviceName := appName + "-e2ee" + switch backend { + case sdkkeyring.BackendMemory: + return newKeystore(keyring.NewArrayKeyring(nil), sdkkeyring.BackendMemory), err + case sdkkeyring.BackendTest: + db, err = keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + ServiceName: serviceName, + FileDir: filepath.Join(rootDir, keyringTestDirName), + FilePasswordFunc: func(_ string) (string, error) { + return "test", nil + }, + }) + case sdkkeyring.BackendFile: + fileDir := filepath.Join(rootDir, keyringFileDirName) + db, err = keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + ServiceName: serviceName, + FileDir: fileDir, + FilePasswordFunc: newRealPrompt(fileDir, userInput), + }) + case sdkkeyring.BackendOS: + db, err = keyring.Open(keyring.Config{ + ServiceName: serviceName, + FileDir: rootDir, + KeychainTrustApplication: true, + FilePasswordFunc: newRealPrompt(rootDir, userInput), + }) + case sdkkeyring.BackendKWallet: + db, err = keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.KWalletBackend}, + ServiceName: "kdewallet", + KWalletAppID: serviceName, + KWalletFolder: "", + }) + case sdkkeyring.BackendPass: + prefix := fmt.Sprintf(passKeyringPrefix, serviceName) + db, err = keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.PassBackend}, + ServiceName: serviceName, + PassPrefix: prefix, + }) + default: + return nil, errorsmod.Wrap(sdkkeyring.ErrUnknownBacked, backend) + } + + if err != nil { + return nil, err + } + + return newKeystore(db, backend), nil +} + +var _ Keyring = keystore{} + +type keystore struct { + db keyring.Keyring + backend string +} + +func newKeystore(kr keyring.Keyring, backend string) keystore { + return keystore{ + db: kr, + backend: backend, + } +} + +func (ks keystore) Get(name string) ([]byte, error) { + item, err := ks.db.Get(name) + if err != nil { + return nil, err + } + + return item.Data, nil +} + +func (ks keystore) Set(name string, secret []byte) error { + return ks.db.Set(keyring.Item{ + Key: name, + Data: secret, + Label: name, + }) +} + +func newRealPrompt(dir string, buf io.Reader) func(string) (string, error) { + return func(prompt string) (string, error) { + keyhashStored := false + keyhashFilePath := filepath.Join(dir, "keyhash") + + var keyhash []byte + + _, err := os.Stat(keyhashFilePath) + + switch { + case err == nil: + keyhash, err = os.ReadFile(keyhashFilePath) + if err != nil { + return "", errorsmod.Wrap(err, fmt.Sprintf("failed to read %s", keyhashFilePath)) + } + + keyhashStored = true + + case os.IsNotExist(err): + keyhashStored = false + + default: + return "", errorsmod.Wrap(err, fmt.Sprintf("failed to open %s", keyhashFilePath)) + } + + failureCounter := 0 + + for { + failureCounter++ + if failureCounter > maxPassphraseEntryAttempts { + return "", sdkkeyring.ErrMaxPassPhraseAttempts + } + + buf := bufio.NewReader(buf) + pass, err := input.GetPassword(fmt.Sprintf("Enter keyring passphrase (attempt %d/%d):", failureCounter, maxPassphraseEntryAttempts), buf) + if err != nil { + // NOTE: LGTM.io reports a false positive alert that states we are printing the password, + // but we only log the error. + // + // lgtm [go/clear-text-logging] + fmt.Fprintln(os.Stderr, err) + continue + } + + if keyhashStored { + if err := bcrypt.CompareHashAndPassword(keyhash, []byte(pass)); err != nil { + fmt.Fprintln(os.Stderr, "incorrect passphrase") + continue + } + + return pass, nil + } + + reEnteredPass, err := input.GetPassword("Re-enter keyring passphrase:", buf) + if err != nil { + // NOTE: LGTM.io reports a false positive alert that states we are printing the password, + // but we only log the error. + // + // lgtm [go/clear-text-logging] + fmt.Fprintln(os.Stderr, err) + continue + } + + if pass != reEnteredPass { + fmt.Fprintln(os.Stderr, "passphrase do not match") + continue + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(pass), 2) + if err != nil { + fmt.Fprintln(os.Stderr, err) + continue + } + + if err := os.WriteFile(keyhashFilePath, passwordHash, 0o600); err != nil { + return "", err + } + + return pass, nil + } + } +} diff --git a/x/e2ee/keyring/keyring_test.go b/x/e2ee/keyring/keyring_test.go new file mode 100644 index 0000000000..43d09d4de4 --- /dev/null +++ b/x/e2ee/keyring/keyring_test.go @@ -0,0 +1,19 @@ +package keyring + +import ( + "testing" + + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/test-go/testify/require" +) + +func TestKeyring(t *testing.T) { + kr, err := New("cronosd", keyring.BackendTest, t.TempDir(), nil) + require.NoError(t, err) + + require.NoError(t, kr.Set("test", []byte("test"))) + + secret, err := kr.Get("test") + require.NoError(t, err) + require.Equal(t, []byte("test"), secret) +}