diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff7cda300..57d6d33fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * [#1407](https://github.com/crypto-org-chain/cronos/pull/1407) Add end-to-end encryption module. +### Improvements + +* [#1413](https://github.com/crypto-org-chain/cronos/pull/1413) Add custom keyring implementation for e2ee module. + *April 22, 2024* ## v1.2.1 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..ef5690f619 --- /dev/null +++ b/x/e2ee/keyring/keyring_test.go @@ -0,0 +1,47 @@ +package keyring + +import ( + "bytes" + "io" + "testing" + + "filippo.io/age" + "github.com/stretchr/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) + } +}