Skip to content

Commit 26faee9

Browse files
bizkkocubinskitac0turtlesamricottajulienrbrt
authored
refactor: bcrypt key derivation to aead (#509) (#15817)
Co-authored-by: Matt Kocubinski <[email protected]> Co-authored-by: Marko <[email protected]> Co-authored-by: samricotta <[email protected]> Co-authored-by: Julien Robert <[email protected]>
1 parent 428e19f commit 26faee9

File tree

3 files changed

+123
-26
lines changed

3 files changed

+123
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
6969

7070
### Improvements
7171

72+
* (crypto) [#3129](https://github.com/cosmos/cosmos-sdk/pull/3129) New armor and keyring key derivation uses aead and encryption uses chacha20poly
7273
* (x/slashing) [#15580](https://github.com/cosmos/cosmos-sdk/pull/15580) Refactor the validator's missed block signing window to be a chunked bitmap instead of a "logical" bitmap, significantly reducing the storage footprint.
7374
* [#15448](https://github.com/cosmos/cosmos-sdk/pull/15448) Automatically populate the block timestamp for historical queries. In contexts where the block timestamp is needed for previous states, the timestamp will now be set. Note, when querying against a node it must be re-synced in order to be able to automatically populate the block timestamp. Otherwise, the block timestamp will be populated for heights going forward once upgraded.
7475
* (x/gov) [#15554](https://github.com/cosmos/cosmos-sdk/pull/15554) Add proposal result log in `active_proposal` event. When a proposal passes but fails to execute, the proposal result is logged in the `active_proposal` event.

crypto/armor.go

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"io"
88

99
"github.com/cometbft/cometbft/crypto"
10-
"golang.org/x/crypto/openpgp/armor" //nolint:staticcheck // TODO: remove this dependency
10+
"golang.org/x/crypto/argon2"
11+
"golang.org/x/crypto/openpgp/armor" //nolint:staticcheck //TODO: remove this dependency
1112

1213
errorsmod "cosmossdk.io/errors"
14+
"golang.org/x/crypto/chacha20poly1305"
1315

1416
"github.com/cosmos/cosmos-sdk/codec/legacy"
1517
"github.com/cosmos/cosmos-sdk/crypto/keys/bcrypt"
@@ -29,6 +31,18 @@ const (
2931
headerType = "type"
3032
)
3133

34+
var (
35+
kdfHeader = "kdf"
36+
kdfBcrypt = "bcrypt"
37+
kdfArgon2 = "argon2"
38+
)
39+
40+
const (
41+
argon2Time = 1
42+
argon2Memory = 64 * 1024
43+
argon2Threads = 4
44+
)
45+
3246
// BcryptSecurityParameter is security parameter var, and it can be changed within the lcd test.
3347
// Making the bcrypt security parameter a var shouldn't be a security issue:
3448
// One can't verify an invalid key by maliciously changing the bcrypt
@@ -131,8 +145,8 @@ func unarmorBytes(armorStr, blockType string) (bz []byte, header map[string]stri
131145
func EncryptArmorPrivKey(privKey cryptotypes.PrivKey, passphrase, algo string) string {
132146
saltBytes, encBytes := encryptPrivKey(privKey, passphrase)
133147
header := map[string]string{
134-
"kdf": "bcrypt",
135-
"salt": fmt.Sprintf("%X", saltBytes),
148+
kdfHeader: kdfArgon2,
149+
"salt": fmt.Sprintf("%X", saltBytes),
136150
}
137151

138152
if algo != "" {
@@ -144,20 +158,22 @@ func EncryptArmorPrivKey(privKey cryptotypes.PrivKey, passphrase, algo string) s
144158
return armorStr
145159
}
146160

147-
// encrypt the given privKey with the passphrase using a randomly
148-
// generated salt and the xsalsa20 cipher. returns the salt and the
149-
// encrypted priv key.
150161
func encryptPrivKey(privKey cryptotypes.PrivKey, passphrase string) (saltBytes, encBytes []byte) {
151162
saltBytes = crypto.CRandBytes(16)
152-
key, err := bcrypt.GenerateFromPassword(saltBytes, []byte(passphrase), BcryptSecurityParameter)
163+
164+
key := argon2.IDKey([]byte(passphrase), saltBytes, argon2Time, argon2Memory, argon2Threads, chacha20poly1305.KeySize)
165+
privKeyBytes := legacy.Cdc.MustMarshal(privKey)
166+
167+
aead, err := chacha20poly1305.New(key)
153168
if err != nil {
154-
panic(errorsmod.Wrap(err, "error generating bcrypt key from passphrase"))
169+
panic(errorsmod.Wrap(err, "error generating cypher from key"))
155170
}
156171

157-
key = crypto.Sha256(key) // get 32 bytes
158-
privKeyBytes := legacy.Cdc.MustMarshal(privKey)
172+
nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(privKeyBytes)+aead.Overhead()) // Nonce is fixed to maintain consistency, each key is generated at every encryption using a random salt.
173+
174+
encBytes = aead.Seal(nil, nonce, privKeyBytes, nil)
159175

160-
return saltBytes, xsalsa20symmetric.EncryptSymmetric(privKeyBytes, key)
176+
return saltBytes, encBytes
161177
}
162178

163179
// UnarmorDecryptPrivKey returns the privkey byte slice, a string of the algo type, and an error
@@ -171,8 +187,8 @@ func UnarmorDecryptPrivKey(armorStr, passphrase string) (privKey cryptotypes.Pri
171187
return privKey, "", fmt.Errorf("unrecognized armor type: %v", blockType)
172188
}
173189

174-
if header["kdf"] != "bcrypt" {
175-
return privKey, "", fmt.Errorf("unrecognized KDF type: %v", header["kdf"])
190+
if header[kdfHeader] != kdfBcrypt && header[kdfHeader] != kdfArgon2 {
191+
return privKey, "", fmt.Errorf("unrecognized KDF type: %v", header[kdfHeader])
176192
}
177193

178194
if header["salt"] == "" {
@@ -184,7 +200,7 @@ func UnarmorDecryptPrivKey(armorStr, passphrase string) (privKey cryptotypes.Pri
184200
return privKey, "", fmt.Errorf("error decoding salt: %v", err.Error())
185201
}
186202

187-
privKey, err = decryptPrivKey(saltBytes, encBytes, passphrase)
203+
privKey, err = decryptPrivKey(saltBytes, encBytes, passphrase, header[kdfHeader])
188204

189205
if header[headerType] == "" {
190206
header[headerType] = defaultAlgo
@@ -193,18 +209,45 @@ func UnarmorDecryptPrivKey(armorStr, passphrase string) (privKey cryptotypes.Pri
193209
return privKey, header[headerType], err
194210
}
195211

196-
func decryptPrivKey(saltBytes, encBytes []byte, passphrase string) (privKey cryptotypes.PrivKey, err error) {
197-
key, err := bcrypt.GenerateFromPassword(saltBytes, []byte(passphrase), BcryptSecurityParameter)
198-
if err != nil {
199-
return privKey, errorsmod.Wrap(err, "error generating bcrypt key from passphrase")
200-
}
212+
func decryptPrivKey(saltBytes, encBytes []byte, passphrase, kdf string) (privKey cryptotypes.PrivKey, err error) {
213+
// Key derivation
214+
var (
215+
key []byte
216+
privKeyBytes []byte
217+
)
218+
219+
// Since the argon2 key derivation and chacha encryption was implemented together, it is not possible to have mixed kdf and encryption algorithms
220+
switch kdf {
221+
case kdfArgon2:
222+
key = argon2.IDKey([]byte(passphrase), saltBytes, argon2Time, argon2Memory, argon2Threads, chacha20poly1305.KeySize)
223+
224+
aead, err := chacha20poly1305.New(key)
225+
if err != nil {
226+
return privKey, errorsmod.Wrap(err, "Error generating aead cypher for key.")
227+
} else if len(encBytes) < aead.NonceSize() {
228+
return privKey, errorsmod.Wrap(nil, "Encrypted bytes length is smaller than aead nonce size.")
229+
}
230+
nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(privKeyBytes)+aead.Overhead())
231+
privKeyBytes, err = aead.Open(nil, nonce, encBytes, nil) // Decrypt the message and check it wasn't tampered with.
232+
if err != nil {
233+
return privKey, sdkerrors.ErrWrongPassword
234+
}
235+
case kdfBcrypt:
236+
key, err = bcrypt.GenerateFromPassword(saltBytes, []byte(passphrase), BcryptSecurityParameter)
237+
if err != nil {
238+
return privKey, errorsmod.Wrap(err, "Error generating bcrypt cypher for key.")
239+
}
240+
key = crypto.Sha256(key) // Get 32 bytes
241+
privKeyBytes, err = xsalsa20symmetric.DecryptSymmetric(encBytes, key)
201242

202-
key = crypto.Sha256(key) // Get 32 bytes
243+
if err == xsalsa20symmetric.ErrCiphertextDecrypt {
244+
return privKey, sdkerrors.ErrWrongPassword
245+
}
246+
default:
247+
return privKey, errorsmod.Wrap(nil, fmt.Sprintf("Unrecognized key derivation function (kdf) header: %s.", kdf))
248+
}
203249

204-
privKeyBytes, err := xsalsa20symmetric.DecryptSymmetric(encBytes, key)
205-
if err != nil && err == xsalsa20symmetric.ErrCiphertextDecrypt {
206-
return privKey, sdkerrors.ErrWrongPassword
207-
} else if err != nil {
250+
if err != nil {
208251
return privKey, err
209252
}
210253

crypto/armor_test.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"testing"
99

1010
cmtcrypto "github.com/cometbft/cometbft/crypto"
11-
"github.com/cometbft/cometbft/crypto/xsalsa20symmetric"
11+
"github.com/cosmos/cosmos-sdk/crypto/xsalsa20symmetric"
12+
13+
_ "github.com/cosmos/cosmos-sdk/runtime"
1214
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/require"
1416

@@ -23,7 +25,6 @@ import (
2325
"github.com/cosmos/cosmos-sdk/crypto/keys/bcrypt"
2426
"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
2527
cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types"
26-
_ "github.com/cosmos/cosmos-sdk/runtime"
2728
"github.com/cosmos/cosmos-sdk/testutil/configurator"
2829
"github.com/cosmos/cosmos-sdk/types"
2930
)
@@ -200,3 +201,55 @@ func TestArmor(t *testing.T) {
200201
assert.Equal(t, blockType, blockType2)
201202
assert.Equal(t, data, data2)
202203
}
204+
205+
func TestBcryptLegacyEncryption(t *testing.T) {
206+
privKey := secp256k1.GenPrivKey()
207+
saltBytes := cmtcrypto.CRandBytes(16)
208+
passphrase := "passphrase"
209+
privKeyBytes := legacy.Cdc.MustMarshal(privKey)
210+
211+
// Bcrypt + Aead
212+
headerBcrypt := map[string]string{
213+
"kdf": "bcrypt",
214+
"salt": fmt.Sprintf("%X", saltBytes),
215+
}
216+
keyBcrypt, _ := bcrypt.GenerateFromPassword(saltBytes, []byte(passphrase), 12) // Legacy key generation
217+
keyBcrypt = cmtcrypto.Sha256(keyBcrypt)
218+
219+
// bcrypt + xsalsa20symmetric
220+
encBytesBcryptXsalsa20symetric := xsalsa20symmetric.EncryptSymmetric(privKeyBytes, keyBcrypt)
221+
222+
type testCase struct {
223+
description string
224+
armor string
225+
}
226+
227+
for _, scenario := range []testCase{
228+
{
229+
description: "Argon2 + Aead",
230+
armor: crypto.EncryptArmorPrivKey(privKey, "passphrase", ""),
231+
},
232+
{
233+
description: "Bcrypt + xsalsa20symmetric",
234+
armor: crypto.EncodeArmor("TENDERMINT PRIVATE KEY", headerBcrypt, encBytesBcryptXsalsa20symetric),
235+
},
236+
} {
237+
t.Run(scenario.description, func(t *testing.T) {
238+
_, _, err := crypto.UnarmorDecryptPrivKey(scenario.armor, "wrongpassphrase")
239+
require.Error(t, err)
240+
decryptedPrivKey, _, err := crypto.UnarmorDecryptPrivKey(scenario.armor, "passphrase")
241+
require.NoError(t, err)
242+
require.True(t, privKey.Equals(decryptedPrivKey))
243+
})
244+
}
245+
246+
// Test wrong kdf header
247+
headerWithoutKdf := map[string]string{
248+
"kdf": "wrongKdf",
249+
"salt": fmt.Sprintf("%X", saltBytes),
250+
}
251+
252+
_, _, err := crypto.UnarmorDecryptPrivKey(crypto.EncodeArmor("TENDERMINT PRIVATE KEY", headerWithoutKdf, encBytesBcryptXsalsa20symetric), "passphrase")
253+
require.Error(t, err)
254+
require.Equal(t, "unrecognized KDF type: wrongKdf", err.Error())
255+
}

0 commit comments

Comments
 (0)