From 36a6b02cf2a702ffa45704af2a048764a47303c2 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 29 Apr 2024 12:46:26 +0800 Subject: [PATCH] Problem: no keyring interface for e2ee to store arbitrary payload (#1413) changelo add age encrypt/decrypt in unit test Update x/e2ee/keyring/keyring.go Signed-off-by: yihuang fix lint --- CHANGELOG.md | 1 + go.mod | 6 +- go.sum | 2 + x/e2ee/keyring/keyring.go | 202 +++++++++++++++++++++++++++++++++ x/e2ee/keyring/keyring_test.go | 47 ++++++++ 5 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 x/e2ee/keyring/keyring.go create mode 100644 x/e2ee/keyring/keyring_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c8613f9153..eee050dd5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * (versiondb) [#1379](https://github.com/crypto-org-chain/cronos/pull/1379) Flush versiondb when graceful shutdown, make rocksdb upgrade smooth. * (store) [#1378](https://github.com/crypto-org-chain/cronos/pull/1378) Upgrade rocksdb to `v8.11.3`. * (versiondb) [#1387](https://github.com/crypto-org-chain/cronos/pull/1387) Add dedicated config section for versiondb, prepare for sdk 0.50 integration. +* [#1413](https://github.com/crypto-org-chain/cronos/pull/1413) Add custom keyring implementation for e2ee module. ### Bug Fixes diff --git a/go.mod b/go.mod index cee4467e64..95c85bcbc7 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( cosmossdk.io/x/evidence v0.1.0 cosmossdk.io/x/feegrant v0.1.0 cosmossdk.io/x/upgrade v0.1.1 + filippo.io/age v1.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 +42,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 +61,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 +228,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/go.sum b/go.sum index 8cc914283c..1884d9987a 100644 --- a/go.sum +++ b/go.sum @@ -211,6 +211,8 @@ cosmossdk.io/x/feegrant v0.1.0/go.mod h1:4r+FsViJRpcZif/yhTn+E0E6OFfg4n0Lx+6cCtn cosmossdk.io/x/upgrade v0.1.1 h1:aoPe2gNvH+Gwt/Pgq3dOxxQVU3j5P6Xf+DaUJTDZATc= cosmossdk.io/x/upgrade v0.1.1/go.mod h1:MNLptLPcIFK9CWt7Ra//8WUZAxweyRDNcbs5nkOcQy0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= +filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= diff --git a/x/e2ee/keyring/keyring.go b/x/e2ee/keyring/keyring.go new file mode 100644 index 0000000000..44b4bd6b8e --- /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" //nolint: gosec + 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), nil + 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..56ef87ee74 --- /dev/null +++ b/x/e2ee/keyring/keyring_test.go @@ -0,0 +1,47 @@ +package keyring + +import ( + "bytes" + "io" + "testing" + + "filippo.io/age" + "github.com/test-go/testify/require" + + "github.com/cosmos/cosmos-sdk/crypto/keyring" +) + +func TestKeyring(t *testing.T) { + kr, err := New("cronosd", keyring.BackendTest, t.TempDir(), nil) + require.NoError(t, err) + + identity, err := age.GenerateX25519Identity() + require.NoError(t, err) + + var ciphertext []byte + { + dst := bytes.NewBuffer(nil) + writer, err := age.Encrypt(dst, identity.Recipient()) + require.NoError(t, err) + writer.Write([]byte("test")) + writer.Close() + ciphertext = dst.Bytes() + } + + require.NoError(t, kr.Set("test", []byte(identity.String()))) + + secret, err := kr.Get("test") + require.NoError(t, err) + + identity, err = age.ParseX25519Identity(string(secret)) + require.NoError(t, err) + + { + reader, err := age.Decrypt(bytes.NewReader(ciphertext), identity) + require.NoError(t, err) + bz, err := io.ReadAll(reader) + require.NoError(t, err) + + require.Equal(t, []byte("test"), bz) + } +}