Skip to content

Commit 3fa2199

Browse files
authored
Merge branch 'main' into ma/coalesce-rotation-power-updates
2 parents b7b74e9 + 1236be8 commit 3fa2199

26 files changed

Lines changed: 497 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
5252
* (staking) [#26440](https://github.com/cosmos/cosmos-sdk/pull/26440) Add basic key rotation for validator consensus keys.
5353
* (crypto) [#26436](https://github.com/cosmos/cosmos-sdk/pull/26436) Add ML-DSA-65 (FIPS 204) post-quantum validator consensus key type, with SDK key wrappers, Amino + interface-registry registration, multisig support, and a `hd.MlDsa65Type` constant.
5454
* (blockstm) [#26467](https://github.com/cosmos/cosmos-sdk/pull/26467) Track existence for `Has()` reads to reduce false conflicts.
55+
* (crypto) [#26472](https://github.com/cosmos/cosmos-sdk/pull/26472) Add ML-DSA-65 (FIPS 204) support for user account keys: mnemonic-based keyring creation/recovery (`--algo ml_dsa_65`), transaction signing/verification, and an ante-handler signature-verification gas cost (`Params.SigVerifyCostMlDsa65`).
5556

5657
### Improvements
5758

baseapp/block_gas_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func TestBaseApp_BlockGas(t *testing.T) {
175175
require.Equal(t, []byte("ok"), okValue)
176176
}
177177
// check block gas is always consumed
178-
baseGas := uint64(58359) // baseGas is the gas consumed before tx msg
178+
baseGas := uint64(58386) // baseGas is the gas consumed before tx msg
179179
expGasConsumed := min(addUint64Saturating(tc.gasToConsume, baseGas), uint64(simtestutil.DefaultConsensusParams.Block.MaxGas))
180180
require.Equal(t, int(expGasConsumed), int(ctx.BlockGasMeter().GasConsumed()))
181181
// tx fee is always deducted

client/keys/add.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,11 @@ func runAddCmd(ctx client.Context, cmd *cobra.Command, args []string, inBuf *buf
244244
}
245245

246246
var pk cryptotypes.PubKey
247-
// create an empty pubkey in order to get the algo TypeUrl.
248-
tempAny, err := codectypes.NewAnyWithValue(algo.Generate()([]byte{}).PubKey())
247+
// Generate a throwaway key from a zero seed solely to obtain the algo's
248+
// pubkey TypeUrl. A 32-byte seed is required by all supported account
249+
// algos (e.g. ML-DSA-65 panics on a wrong-length seed); the key value
250+
// itself is discarded.
251+
tempAny, err := codectypes.NewAnyWithValue(algo.Generate()(make([]byte, 32)).PubKey())
249252
if err != nil {
250253
return err
251254
}

crypto/hd/algo.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ const (
2222
// It is currently not supported for end-user keys (wallets/ledgers).
2323
Bls12_381Type = PubKeyType("bls12_381")
2424
// MlDsa65Type represents the NIST ML-DSA-65 (FIPS 204) post-quantum
25-
// signature scheme. It is currently not supported for end-user keys
26-
// (wallets/ledgers) and is intended for validator key use.
25+
// signature scheme. It is supported for both validator keys and end-user
26+
// account keys via the software keyring. Ledger/hardware wallets are not
27+
// supported (no device implements ML-DSA today).
2728
MlDsa65Type = PubKeyType("ml_dsa_65")
2829
)
2930

crypto/hd/mldsa65.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package hd
2+
3+
import (
4+
"github.com/cosmos/cosmos-sdk/crypto/keys/mldsa65"
5+
"github.com/cosmos/cosmos-sdk/crypto/types"
6+
)
7+
8+
// MlDsa65 is the ML-DSA-65 (FIPS 204) account key algorithm.
9+
var MlDsa65 = mldsa65Algo{}
10+
11+
type mldsa65Algo struct{}
12+
13+
func (mldsa65Algo) Name() PubKeyType {
14+
return MlDsa65Type
15+
}
16+
17+
// Derive reuses the secp256k1 BIP32 derivation. It returns the 32-byte
18+
// BIP32-derived key for the given mnemonic, passphrase, and HD path, which is
19+
// used directly as the ML-DSA-65 keygen seed (mldsa65 SeedSize == 32). This
20+
// gives mnemonic recovery and per-path account separation without inventing a
21+
// new derivation scheme.
22+
func (mldsa65Algo) Derive() DeriveFn {
23+
return Secp256k1.Derive()
24+
}
25+
26+
// Generate builds an ML-DSA-65 private key from the 32-byte derived seed.
27+
func (mldsa65Algo) Generate() GenerateFn {
28+
return func(bz []byte) types.PrivKey {
29+
privKey, err := mldsa65.GenPrivKeyFromSeed(bz)
30+
if err != nil {
31+
// A non-32-byte seed only reaches here from a caller passing
32+
// untrusted/dummy bytes directly (e.g. ImportPrivKeyHex); the BIP32
33+
// derivation path always supplies a 32-byte seed. Such callers must
34+
// guard their input — see keyring.generatePrivKey.
35+
panic(err)
36+
}
37+
return &privKey
38+
}
39+
}

crypto/hd/mldsa65_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package hd_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/cosmos/cosmos-sdk/crypto/hd"
9+
"github.com/cosmos/cosmos-sdk/crypto/keys/mldsa65"
10+
)
11+
12+
const (
13+
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
14+
hdPath0 = "m/44'/118'/0'/0/0"
15+
hdPath1 = "m/44'/118'/0'/0/1"
16+
)
17+
18+
func TestMlDsa65Name(t *testing.T) {
19+
require.Equal(t, hd.MlDsa65Type, hd.MlDsa65.Name())
20+
}
21+
22+
func TestMlDsa65DeriveAndGenerate(t *testing.T) {
23+
derive := hd.MlDsa65.Derive()
24+
gen := hd.MlDsa65.Generate()
25+
26+
seed, err := derive(testMnemonic, "", hdPath0)
27+
require.NoError(t, err)
28+
require.Len(t, seed, 32) // matches mldsa65 SeedSize
29+
30+
priv := gen(seed)
31+
require.NotNil(t, priv)
32+
_, ok := priv.(*mldsa65.PrivKey)
33+
require.True(t, ok)
34+
}
35+
36+
func TestMlDsa65GenerateZeroSeedNoPanic(t *testing.T) {
37+
require.NotPanics(t, func() {
38+
priv := hd.MlDsa65.Generate()(make([]byte, 32))
39+
require.NotNil(t, priv)
40+
require.Equal(t, "ml_dsa_65", priv.PubKey().Type())
41+
})
42+
}
43+
44+
func TestMlDsa65DerivationDeterministic(t *testing.T) {
45+
derive := hd.MlDsa65.Derive()
46+
gen := hd.MlDsa65.Generate()
47+
48+
mk := func(path string) []byte {
49+
seed, err := derive(testMnemonic, "", path)
50+
require.NoError(t, err)
51+
return gen(seed).PubKey().Bytes()
52+
}
53+
54+
// Same mnemonic+path -> same key (recovery).
55+
require.Equal(t, mk(hdPath0), mk(hdPath0))
56+
// Different path -> different account (path salt).
57+
require.NotEqual(t, mk(hdPath0), mk(hdPath1))
58+
}

crypto/keyring/keyring.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ func newKeystore(kr keyring.Keyring, cdc codec.Codec, backend string, opts ...Op
210210
// Default options for keybase, these can be overwritten using the
211211
// Option function
212212
options := Options{
213-
SupportedAlgos: SigningAlgoList{hd.Secp256k1},
213+
SupportedAlgos: SigningAlgoList{hd.Secp256k1, hd.MlDsa65},
214214
SupportedAlgosLedger: SigningAlgoList{hd.Secp256k1},
215215
}
216216

@@ -334,6 +334,18 @@ func (ks keystore) ImportPrivKey(uid, armor, passphrase string) error {
334334
return nil
335335
}
336336

337+
// generatePrivKey runs the signing algorithm's key generator on raw bytes,
338+
// converting a panic from invalid input (e.g. a wrong-length ML-DSA-65 seed
339+
// supplied via import-hex) into a returned error instead of crashing.
340+
func generatePrivKey(algo SignatureAlgo, bz []byte) (priv types.PrivKey, err error) {
341+
defer func() {
342+
if r := recover(); r != nil {
343+
err = errorsmod.Wrapf(sdkerrors.ErrInvalidRequest, "invalid private key bytes for %s: %v", algo.Name(), r)
344+
}
345+
}()
346+
return algo.Generate()(bz), nil
347+
}
348+
337349
func (ks keystore) ImportPrivKeyHex(uid, privKey, algoStr string) error {
338350
if _, err := ks.Key(uid); err == nil {
339351
return errorsmod.Wrap(ErrOverwriteKey, uid)
@@ -349,7 +361,10 @@ func (ks keystore) ImportPrivKeyHex(uid, privKey, algoStr string) error {
349361
if err != nil {
350362
return err
351363
}
352-
priv := algo.Generate()(decodedPriv)
364+
priv, err := generatePrivKey(algo, decodedPriv)
365+
if err != nil {
366+
return err
367+
}
353368
_, err = ks.writeLocalKey(uid, priv)
354369
if err != nil {
355370
return err

crypto/keyring/keyring_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,4 +2083,72 @@ func assertKeysExist(t *testing.T, kr Keyring, names ...string) {
20832083
}
20842084
}
20852085

2086+
func TestNewMnemonicMlDsa65(t *testing.T) {
2087+
cdc := getCodec()
2088+
kb := NewInMemory(cdc)
2089+
2090+
// Assert hd.MlDsa65 is among the supported algorithms.
2091+
algos, _ := kb.SupportedAlgorithms()
2092+
require.True(t, algos.Contains(hd.MlDsa65), "expected hd.MlDsa65 in SupportedAlgos")
2093+
2094+
// Create a new mnemonic-backed ML-DSA-65 key.
2095+
rec, mnemonic, err := kb.NewMnemonic("mldsa", English, sdk.FullFundraiserPath, DefaultBIP39Passphrase, hd.MlDsa65)
2096+
require.NoError(t, err)
2097+
require.NotEmpty(t, mnemonic)
2098+
2099+
// Get the pubkey from the new record.
2100+
pub, err := rec.GetPubKey()
2101+
require.NoError(t, err)
2102+
require.Equal(t, "ml_dsa_65", pub.Type())
2103+
require.Len(t, pub.Address(), 20)
2104+
2105+
// Re-read the key by name; verify address and key stability.
2106+
rec2, err := kb.Key("mldsa")
2107+
require.NoError(t, err)
2108+
pub2, err := rec2.GetPubKey()
2109+
require.NoError(t, err)
2110+
require.True(t, pub.Equals(pub2))
2111+
}
2112+
2113+
func TestMlDsa65SignVerifyThroughKeyring(t *testing.T) {
2114+
cdc := getCodec()
2115+
kb := NewInMemory(cdc)
2116+
2117+
rec, _, err := kb.NewMnemonic("mldsa-signer", English, sdk.FullFundraiserPath, DefaultBIP39Passphrase, hd.MlDsa65)
2118+
require.NoError(t, err)
2119+
2120+
msg := []byte("sign me with a post-quantum key")
2121+
sig, pub, err := kb.Sign("mldsa-signer", msg, signing.SignMode_SIGN_MODE_DIRECT)
2122+
require.NoError(t, err)
2123+
require.Equal(t, "ml_dsa_65", pub.Type())
2124+
require.True(t, pub.VerifySignature(msg, sig))
2125+
2126+
recPub, err := rec.GetPubKey()
2127+
require.NoError(t, err)
2128+
require.True(t, pub.Equals(recPub))
2129+
}
2130+
2131+
func TestImportPrivKeyHexMlDsa65WrongLength(t *testing.T) {
2132+
kb := NewInMemory(getCodec())
2133+
// 31 bytes -> 62 hex chars; not a valid 32-byte ML-DSA-65 seed.
2134+
badHex := strings.Repeat("ab", 31)
2135+
require.NotPanics(t, func() {
2136+
err := kb.ImportPrivKeyHex("bad", badHex, string(hd.MlDsa65Type))
2137+
require.Error(t, err)
2138+
})
2139+
}
2140+
2141+
func TestImportPrivKeyHexMlDsa65Valid(t *testing.T) {
2142+
kb := NewInMemory(getCodec())
2143+
seedHex := strings.Repeat("11", 32) // 32 bytes
2144+
err := kb.ImportPrivKeyHex("good", seedHex, string(hd.MlDsa65Type))
2145+
require.NoError(t, err)
2146+
2147+
rec, err := kb.Key("good")
2148+
require.NoError(t, err)
2149+
pub, err := rec.GetPubKey()
2150+
require.NoError(t, err)
2151+
require.Equal(t, "ml_dsa_65", pub.Type())
2152+
}
2153+
20862154
func accAddr(k *Record) (sdk.AccAddress, error) { return k.GetAddress() }

crypto/keys/mldsa65/bench_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package mldsa65_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/cosmos/cosmos-sdk/crypto/keys/internal/benchmarking"
7+
"github.com/cosmos/cosmos-sdk/crypto/keys/mldsa65"
8+
)
9+
10+
func BenchmarkSigning(b *testing.B) {
11+
b.ReportAllocs()
12+
priv, err := mldsa65.GenPrivKey()
13+
if err != nil {
14+
b.Fatal(err)
15+
}
16+
benchmarking.BenchmarkSigning(b, &priv)
17+
}
18+
19+
func BenchmarkVerification(b *testing.B) {
20+
b.ReportAllocs()
21+
priv, err := mldsa65.GenPrivKey()
22+
if err != nil {
23+
b.Fatal(err)
24+
}
25+
benchmarking.BenchmarkVerification(b, &priv)
26+
}

crypto/keys/mldsa65/key.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ func GenPrivKey() (PrivKey, error) {
4646
return PrivKey{Key: sk.Bytes()}, nil
4747
}
4848

49+
// GenPrivKeyFromSeed deterministically derives an ML-DSA-65 private key from a
50+
// 32-byte seed (mldsa.SeedSize). The same seed always yields the same key,
51+
// which is what makes mnemonic-based account recovery possible.
52+
func GenPrivKeyFromSeed(seed []byte) (PrivKey, error) {
53+
sk, err := mldsa.GenPrivKeyFromSeed(seed)
54+
if err != nil {
55+
return PrivKey{}, err
56+
}
57+
return PrivKey{Key: sk.Bytes()}, nil
58+
}
59+
4960
// Bytes returns the serialized private key bytes.
5061
func (privKey PrivKey) Bytes() []byte {
5162
return privKey.Key
@@ -117,10 +128,10 @@ var (
117128
_ codec.AminoMarshaler = &PubKey{}
118129
)
119130

120-
// Address returns the validator address: SHA256(pubkey) truncated to 20 bytes,
121-
// matching the convention used by other CometBFT validator key types. ML-DSA-65
122-
// is not intended for account-level use; SDK accounts should not derive
123-
// addresses from this key.
131+
// Address returns the account/validator address: SHA256(pubkey) truncated to 20
132+
// bytes, matching the convention used by ed25519 keys. This scheme is valid for
133+
// both CometBFT validator addresses and SDK account addresses; it MUST NOT change
134+
// once accounts exist, as that would be a breaking change to derived addresses.
124135
func (pubKey PubKey) Address() crypto.Address {
125136
pk, err := mldsa.NewPubKeyFromBytes(pubKey.Key)
126137
if err != nil {

0 commit comments

Comments
 (0)