From 761cf1513fc271fcd1690bec6140379ff42c802b Mon Sep 17 00:00:00 2001 From: envestcc Date: Wed, 10 Jun 2026 09:21:33 +0800 Subject: [PATCH 1/9] fix(staking): require BLS proof-of-possession at candidate register/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL security fix for the BLS rogue-key aggregate-forgery attack against IIP-52's same-message FastAggregateVerify path. IIP-52 verifies the COMMIT-vote BLS aggregate signature against the sum of registered candidate BLS public keys: blstAggregateSignature.FastAggregateVerify(true, Σ pk_i, msg, dst) with dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_". The _POP_ suffix names the IRTF "Proof of Possession" ciphersuite, which is only sound when each registered pubkey has been accompanied at registration time by a signature proving knowledge of the corresponding secret key. Today registration validates only key format + subgroup membership: crypto.BLS12381PublicKeyFromBytes(blsPubKey) No PoP is required. A registered candidate can publish pk_rogue = g^x − Σ(other delegates' pubkeys) computed from public information, supply a single signature σ = sign(x, msg) and a bitmap that claims every delegate signed, and FastAggregateVerify collapses Σ pk_i + pk_rogue to g^x, accepting σ. The bitmap satisfies isMajorityCount, so one delegate forges a 2/3+ quorum certificate and the consensus safety property is broken. The window is latent: IIP-52's ToBeEnabledBlockHeight = math.MaxUint64 so this code path is not live on any chain yet. It must be fixed before activation. - Add a 96-byte blsPop field to CandidateBasicInfo proto (iotex-proto PR pushed; go.mod replace updated to envestcc/iotex-proto@7a486f6) - New action/protocol/staking/bls_pop.go defines the PoP signing root: BLSPopSigningRoot(blsPubKey, ownerAddress) = SHA-256("IOTEX_BLS_POP_v1" || blsPubKey || ownerAddress.Bytes()) Binding all three values blocks the three replay variants: (a) attacker without the secret cannot sign over the canonical message at all — closes rogue key; (b) PoP for owner A does not validate for owner B — closes candidate-substitution replay; (c) "IOTEX_BLS_POP_v1" domain prefix is disjoint from any other BLS DST iotex uses, so a PoP cannot be replayed as a consensus vote or vice versa, and a future fork can rotate via "v2". - New FeatureCtx.EnforceBLSPoP shares IsToBeEnabled height with EnableBLSAggregation today but is a semantically distinct switch so the two can diverge if a hotfix or fork-height adjustment is needed. - action.CandidateRegister and action.CandidateUpdate carry blsPop through their constructors, Proto/LoadProto, and accessors. NewCandidateRegisterWithBLS and NewCandidateUpdateWithBLS now accept a blsPop parameter; pre-fork callers may pass nil. - handleCandidateRegister, handleCandidateUpdate, and handleCandidateUpdateByOperator call VerifyBLSPop before writing Candidate.BLSPubKey when EnforceBLSPoP is active. Failure returns ReceiptStatus_ErrUnauthorizedOperator. - TestBLSPop_RogueKeyAttackBlocked is the regression guard: it models the attacker trying to register a pubkey for which they do not have the secret, demonstrates the only PoP they can produce (signed under their own secret) does NOT validate against the target pubkey, and confirms the control case (a legitimate registrant can produce a valid PoP). - ioctl/SDK wiring: stake2register.go and stake2update.go currently pass nil for blsPop with TODO markers. These need a flag to ingest a BLS private key and derive the PoP. Pre-fork the empty PoP is accepted; post-fork the same call will fail with the new check, so the tooling MUST be updated before the fork. - Migration: existing pre-fork candidate records have no PoP. Before the fork activates, all active candidates must submit a candidateUpdate carrying a valid blsPop; the alternative is a fork-block state migration that drops BLS pubkeys without accompanying PoPs from ActiveCandidates. IIP draft will document the chosen approach. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/candidate_register.go | 28 ++++ action/candidate_update.go | 28 +++- action/candidateregister_test.go | 4 +- action/candidateupdate_test.go | 2 +- action/protocol/context.go | 12 ++ action/protocol/staking/bls_pop.go | 87 ++++++++++++ action/protocol/staking/bls_pop_test.go | 173 ++++++++++++++++++++++++ action/protocol/staking/handlers.go | 24 ++++ action/signedaction.go | 10 +- api/web3server_integrity_test.go | 1 + e2etest/native_staking_test.go | 4 +- go.mod | 2 + go.sum | 4 +- ioctl/cmd/action/stake2register.go | 6 +- ioctl/cmd/action/stake2update.go | 5 +- 15 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 action/protocol/staking/bls_pop.go create mode 100644 action/protocol/staking/bls_pop_test.go diff --git a/action/candidate_register.go b/action/candidate_register.go index 6839b959ff..66ddb88ce5 100644 --- a/action/candidate_register.go +++ b/action/candidate_register.go @@ -67,6 +67,11 @@ type CandidateRegister struct { autoStake bool payload []byte blsPubKey []byte + // blsPop is the proof-of-possession signature over + // BLSPopSigningRoot(blsPubKey, ownerAddress). Required at handler + // time once EnforceBLSPoP is active; carried alongside blsPubKey so + // it always travels with the registration. + blsPop []byte } func init() { @@ -149,11 +154,15 @@ func NewCandidateRegister( } // NewCandidateRegisterWithBLS creates a CandidateRegister instance with BLS public key +// and the corresponding proof-of-possession. blsPop must be a 96-byte BLS +// signature over BLSPopSigningRoot(blsPubKey, ownerAddress); the handler +// enforces this once EnforceBLSPoP is active. func NewCandidateRegisterWithBLS( name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr string, duration uint32, autoStake bool, blsPubKey []byte, + blsPop []byte, payload []byte, ) (*CandidateRegister, error) { cr, err := NewCandidateRegister(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr, duration, autoStake, payload) @@ -168,6 +177,10 @@ func NewCandidateRegisterWithBLS( cr.amount = nil cr.blsPubKey = make([]byte, len(blsPubKey)) copy(cr.blsPubKey, blsPubKey) + if len(blsPop) > 0 { + cr.blsPop = make([]byte, len(blsPop)) + copy(cr.blsPop, blsPop) + } return cr, nil } @@ -215,6 +228,13 @@ func (cr *CandidateRegister) BLSPubKey() []byte { return cr.blsPubKey } +// BLSPop returns the BLS proof-of-possession that accompanies the +// blsPubKey. Empty for legacy registrations and for pre-fork +// CandidateRegister actions; required once EnforceBLSPoP is active. +func (cr *CandidateRegister) BLSPop() []byte { + return cr.blsPop +} + // Serialize returns a raw byte stream of the CandidateRegister struct func (cr *CandidateRegister) Serialize() []byte { return byteutil.Must(proto.Marshal(cr.Proto())) @@ -249,6 +269,10 @@ func (cr *CandidateRegister) Proto() *iotextypes.CandidateRegister { case cr.WithBLS(): act.Candidate.BlsPubKey = make([]byte, len(cr.blsPubKey)) copy(act.Candidate.BlsPubKey, cr.blsPubKey) + if len(cr.blsPop) > 0 { + act.Candidate.BlsPop = make([]byte, len(cr.blsPop)) + copy(act.Candidate.BlsPop, cr.blsPop) + } if cr.value != nil { act.StakedAmount = cr.value.String() } @@ -288,6 +312,10 @@ func (cr *CandidateRegister) LoadProto(pbAct *iotextypes.CandidateRegister) erro if withBLS { cr.blsPubKey = make([]byte, len(pbAct.Candidate.GetBlsPubKey())) copy(cr.blsPubKey, pbAct.Candidate.GetBlsPubKey()) + if pop := pbAct.Candidate.GetBlsPop(); len(pop) > 0 { + cr.blsPop = make([]byte, len(pop)) + copy(cr.blsPop, pop) + } } if len(pbAct.GetStakedAmount()) > 0 { amount, ok := new(big.Int).SetString(pbAct.GetStakedAmount(), 10) diff --git a/action/candidate_update.go b/action/candidate_update.go index 4016bef235..2c885638e8 100644 --- a/action/candidate_update.go +++ b/action/candidate_update.go @@ -41,6 +41,11 @@ type CandidateUpdate struct { operatorAddress address.Address rewardAddress address.Address blsPubKey []byte + // blsPop is the proof-of-possession for blsPubKey. Required at + // handler time once EnforceBLSPoP is active: any update that + // introduces or rotates the BLS key must carry a fresh PoP to + // prevent rogue-key attacks. + blsPop []byte } // CandidateUpdateOption defines the method to customize CandidateUpdate @@ -99,7 +104,9 @@ func NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr string) (*Candidate } // NewCandidateUpdateWithBLS creates a CandidateUpdate instance with BLS public key -func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubkey []byte) (*CandidateUpdate, error) { +// and proof-of-possession. blsPop may be empty for pre-fork callers; the +// handler enforces non-empty PoP once EnforceBLSPoP is active. +func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubkey []byte, pop []byte) (*CandidateUpdate, error) { cu, err := NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr) if err != nil { return nil, err @@ -110,6 +117,10 @@ func NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr string, pubk } cu.blsPubKey = make([]byte, len(pubkey)) copy(cu.blsPubKey, pubkey) + if len(pop) > 0 { + cu.blsPop = make([]byte, len(pop)) + copy(cu.blsPop, pop) + } return cu, nil } @@ -127,6 +138,13 @@ func (cu *CandidateUpdate) BLSPubKey() []byte { return cu.blsPubKey } +// BLSPop returns the proof-of-possession for the BLS pubkey carried +// by this update. Empty for updates that do not rotate the BLS key +// and for pre-fork updates; required once EnforceBLSPoP is active. +func (cu *CandidateUpdate) BLSPop() []byte { + return cu.blsPop +} + // WithBLS returns true if the candidate update action is with BLS public key func (cu *CandidateUpdate) WithBLS() bool { return len(cu.blsPubKey) > 0 @@ -159,6 +177,10 @@ func (cu *CandidateUpdate) Proto() *iotextypes.CandidateBasicInfo { act.BlsPubKey = make([]byte, len(cu.blsPubKey)) copy(act.BlsPubKey, cu.blsPubKey) } + if len(cu.blsPop) > 0 { + act.BlsPop = make([]byte, len(cu.blsPop)) + copy(act.BlsPop, cu.blsPop) + } return act } @@ -188,6 +210,10 @@ func (cu *CandidateUpdate) LoadProto(pbAct *iotextypes.CandidateBasicInfo) error if len(pbAct.GetBlsPubKey()) > 0 { cu.blsPubKey = make([]byte, len(pbAct.GetBlsPubKey())) copy(cu.blsPubKey, pbAct.GetBlsPubKey()) + if pop := pbAct.GetBlsPop(); len(pop) > 0 { + cu.blsPop = make([]byte, len(pop)) + copy(cu.blsPop, pop) + } } return nil } diff --git a/action/candidateregister_test.go b/action/candidateregister_test.go index 97e7437c8b..fb27b4e4d3 100644 --- a/action/candidateregister_test.go +++ b/action/candidateregister_test.go @@ -140,7 +140,7 @@ func TestCandidateRegister(t *testing.T) { blsPubKey := blsPrivKey.PublicKey().Bytes() for _, test := range candidateRegisterTestParams { test.blsPubKey = blsPubKey - cr, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, test.blsPubKey, test.Payload) + cr, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, test.blsPubKey, nil, test.Payload) require.Equal(test.Expected, errors.Cause(err)) if err != nil { continue @@ -190,7 +190,7 @@ func TestCandidateRegisterABIEncodeAndDecode(t *testing.T) { t.Run("with public key", func(t *testing.T) { pk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) require.NoError(err) - stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), test.Payload) + stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), nil, test.Payload) require.NoError(err) encode(stake) }) diff --git a/action/candidateupdate_test.go b/action/candidateupdate_test.go index 3f743a178f..3d66b92c2b 100644 --- a/action/candidateupdate_test.go +++ b/action/candidateupdate_test.go @@ -29,7 +29,7 @@ func TestCandidateUpdate(t *testing.T) { require := require.New(t) blsPrivKey, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) require.NoError(err) - cu, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes()) + cu, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes(), nil) require.NoError(err) elp := (&EnvelopeBuilder{}).SetNonce(_cuNonce).SetGasLimit(_cuGasLimit). SetGasPrice(_cuGasPrice).SetAction(cu).Build() diff --git a/action/protocol/context.go b/action/protocol/context.go index 7b190349a2..9612b30072 100644 --- a/action/protocol/context.go +++ b/action/protocol/context.go @@ -172,6 +172,17 @@ type ( // contracts are committed and written back AlwaysWriteCachedContract bool NoCandidateExitQueue bool + // EnforceBLSPoP gates the BLS proof-of-possession requirement at + // candidate register / update. The staking handler validates + // blsPubKey only with BLS12381PublicKeyFromBytes (format + + // subgroup); without a possession proof, IIP-52's planned + // FastAggregateVerify path is vulnerable to a rogue-key + // aggregate-forgery attack (a registered candidate could publish + // pk_rogue = g^x − Σ(other pubkeys) and, once aggregation goes + // live, forge a 2/3+ quorum certificate with a single signature). + // Activating EnforceBLSPoP BEFORE the BLS aggregation fork closes + // the window for collecting un-attested pubkeys. + EnforceBLSPoP bool } // FeatureWithHeightCtx provides feature check functions. @@ -346,6 +357,7 @@ func WithFeatureCtx(ctx context.Context) context.Context { PrePectraEVM: !g.IsYap(height), AlwaysWriteCachedContract: !g.IsYap(height), NoCandidateExitQueue: !g.IsYap(height), + EnforceBLSPoP: g.IsToBeEnabled(height), }, ) } diff --git a/action/protocol/staking/bls_pop.go b/action/protocol/staking/bls_pop.go new file mode 100644 index 0000000000..e3d89a1525 --- /dev/null +++ b/action/protocol/staking/bls_pop.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "crypto/sha256" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" +) + +// blsPopDomain is the iotex-specific domain separator for BLS +// proof-of-possession signatures at candidate register / update time. +// +// Domain separation matters for two reasons: +// 1. It prevents a PoP signature from being replayed as a consensus +// vote signature (or vice versa) — even though both schemes use +// the same BLS ciphersuite DST, the message they sign starts with +// this iotex-application-level tag and so the resulting signing +// root will never collide with a consensus signing root. +// 2. The version suffix ("v1") reserves room for a future fork to +// rotate the PoP scheme without ambiguity. +const blsPopDomain = "IOTEX_BLS_POP_v1" + +// BLSPopSigningRoot returns the bytes that a BLS proof-of-possession +// must be computed over for the given candidate. +// +// Binding three values into the signed message — the domain tag, the BLS +// public key itself, and the candidate owner address — closes the rogue +// key attack and two related replays: +// +// - blsPubKey: forces the signer to know the private key for THIS +// specific BLS pubkey. A rogue pubkey constructed as +// g^x − Σ(other pubkeys) cannot produce a valid PoP because the +// attacker does not know its discrete log. +// - ownerAddress: prevents two distinct candidates from sharing a +// single BLS keypair (and thus a single PoP) without each owner +// independently re-attesting; also prevents a PoP submitted for +// candidate A from being replayed for candidate B by a man-in-the- +// middle who repackages a CandidateRegister tx. +// - blsPopDomain: keeps PoP signatures disjoint from consensus +// signatures, future PoP schemes, and any other BLS-signed iotex +// message that may exist or be added later. +func BLSPopSigningRoot(blsPubKey []byte, ownerAddress address.Address) []byte { + h := sha256.New() + h.Write([]byte(blsPopDomain)) + h.Write(blsPubKey) + if ownerAddress != nil { + h.Write(ownerAddress.Bytes()) + } + return h.Sum(nil) +} + +// SignBLSPop produces a proof-of-possession for the given BLS private +// key, binding it to the candidate owner address. Used by tooling +// (ioctl, SDK) to generate the bls_pop field on CandidateRegister / +// CandidateUpdate transactions. +func SignBLSPop(sk *crypto.BLS12381PrivateKey, ownerAddress address.Address) ([]byte, error) { + if sk == nil { + return nil, errors.New("nil BLS private key") + } + pk := sk.PublicKey().Bytes() + return sk.Sign(BLSPopSigningRoot(pk, ownerAddress)) +} + +// VerifyBLSPop verifies the proof-of-possession against the provided +// pubkey and owner. Returns nil on success. +func VerifyBLSPop(blsPubKey, blsPop []byte, ownerAddress address.Address) error { + if len(blsPubKey) != crypto.BLSPubkeyLength { + return errors.Errorf("invalid BLS pubkey length: got %d, want %d", len(blsPubKey), crypto.BLSPubkeyLength) + } + if len(blsPop) != crypto.BLSAggregateSignatureLength { + return errors.Errorf("invalid BLS PoP length: got %d, want %d", len(blsPop), crypto.BLSAggregateSignatureLength) + } + pk, err := crypto.BLS12381PublicKeyFromBytes(blsPubKey) + if err != nil { + return errors.Wrap(err, "invalid BLS pubkey") + } + if !pk.Verify(BLSPopSigningRoot(blsPubKey, ownerAddress), blsPop) { + return errors.New("BLS proof-of-possession verification failed") + } + return nil +} diff --git a/action/protocol/staking/bls_pop_test.go b/action/protocol/staking/bls_pop_test.go new file mode 100644 index 0000000000..0009449038 --- /dev/null +++ b/action/protocol/staking/bls_pop_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "crypto/sha256" + "encoding/binary" + "testing" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/stretchr/testify/require" +) + +// blsKeyForTest deterministically derives a BLS keypair from a seed so +// the tests are reproducible. +func blsKeyForTest(t *testing.T, seed string) *crypto.BLS12381PrivateKey { + t.Helper() + h := sha256.Sum256([]byte(seed)) + sk, err := crypto.GenerateBLS12381PrivateKey(h[:]) + require.NoError(t, err) + return sk +} + +func addrForTest(t *testing.T, seed string) address.Address { + t.Helper() + h := sha256.Sum256([]byte(seed)) + addr, err := address.FromBytes(h[:20]) + require.NoError(t, err) + return addr +} + +func TestBLSPop_RoundTrip(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "honest-delegate") + owner := addrForTest(t, "owner-address") + + pop, err := SignBLSPop(sk, owner) + require.NoError(err) + require.Len(pop, crypto.BLSAggregateSignatureLength) + + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, owner)) +} + +func TestBLSPop_RejectInvalidLength(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "x") + owner := addrForTest(t, "owner") + + require.Error(VerifyBLSPop([]byte{0x00}, make([]byte, 96), owner), + "short BLS pubkey rejected") + require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), []byte{0x00}, owner), + "short PoP rejected") +} + +func TestBLSPop_RejectWrongOwner(t *testing.T) { + // A PoP issued for one candidate owner must not verify under another + // owner — closes the replay window where an attacker repackages a + // CandidateRegister tx with a different owner address. + require := require.New(t) + sk := blsKeyForTest(t, "delegate") + ownerA := addrForTest(t, "owner-A") + ownerB := addrForTest(t, "owner-B") + + pop, err := SignBLSPop(sk, ownerA) + require.NoError(err) + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, ownerA), + "sanity: PoP verifies for the owner it was signed for") + require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), pop, ownerB), + "PoP must NOT verify under a different owner") +} + +func TestBLSPop_RejectWrongPubkey(t *testing.T) { + // A PoP for one BLS pubkey must not verify against another — closes + // the case where an attacker steals a PoP from someone else's tx and + // pairs it with their own pubkey. + require := require.New(t) + skA := blsKeyForTest(t, "delegate-A") + skB := blsKeyForTest(t, "delegate-B") + owner := addrForTest(t, "owner") + + popA, err := SignBLSPop(skA, owner) + require.NoError(err) + require.Error(VerifyBLSPop(skB.PublicKey().Bytes(), popA, owner), + "PoP for pubkey A must NOT verify against pubkey B") +} + +// TestBLSPop_RogueKeyAttackBlocked is the security regression guard for +// the BLS rogue-key aggregate forgery against IIP-52's +// FastAggregateVerify path. +// +// Threat model: an attacker reads N honest delegates' BLS public keys +// from chain. They pick a private key x they fully control and would +// like to register +// +// pk_rogue = g^x − Σ(other delegates' pubkeys) +// +// in G1. If accepted, the rogue pubkey causes +// FastAggregateVerify(all_pubkeys, σ, msg) to collapse the aggregated +// pubkey to g^x, letting one delegate forge a quorum certificate with +// a single signature. +// +// Constructing pk_rogue requires only public information. What does +// NOT require only public information is knowing its discrete log — +// pk_rogue's secret key is (x − Σ sk_i), and the attacker does not +// know any sk_i. +// +// The PoP mitigation: registration requires a BLS signature over +// BLSPopSigningRoot(pk_rogue, owner). Producing this signature +// requires pk_rogue's secret key. The attacker cannot produce one. +// +// The cleanest test of this property is the abstract one: any pubkey +// for which the actor does not know the secret cannot be the subject +// of a valid PoP — regardless of how the pubkey was constructed. We +// model "the attacker tries to register some pubkey they do not own" +// by using a freshly generated pubkey as pk_rogue and verifying that +// no PoP signed by any other secret key (whether the attacker's or +// anyone else's) validates against it. +func TestBLSPop_RogueKeyAttackBlocked(t *testing.T) { + require := require.New(t) + + // 1. N=24 honest delegates' pubkeys, simulating the on-chain active + // set the attacker reads. The exact bytes are not assertion-load- + // bearing — they only need to be distinct, valid BLS pubkeys. + const N = 24 + honestPubKeys := make([][]byte, N) + for i := 0; i < N; i++ { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(i)) + sk := blsKeyForTest(t, "honest-"+string(buf)) + honestPubKeys[i] = sk.PublicKey().Bytes() + } + + // 2. The attacker's secret. They can sign anything with this, but + // not under any other key's pubkey. + attackerSK := blsKeyForTest(t, "attacker-secret") + + // 3. pk_rogue stand-in: a freshly generated pubkey whose secret the + // attacker does NOT have. (In the wild, this would be the + // explicit g^x − Σ pk_i subtraction; for PoP-verification + // purposes the relevant property is identical — pk for which + // attacker has no sk.) + pkRogue := blsKeyForTest(t, "rogue-target-keypair").PublicKey().Bytes() + rogueOwner := addrForTest(t, "rogue-owner") + + // 4. Attacker attempts to register pkRogue. The only PoP they can + // produce is one signed with attackerSK — but + // BLSPopSigningRoot(pkRogue, ...) was supposed to be signed under + // pkRogue's secret key, not attackerSK's. Verification must reject. + attackerForgedPop, err := attackerSK.Sign(BLSPopSigningRoot(pkRogue, rogueOwner)) + require.NoError(err) + require.Error(VerifyBLSPop(pkRogue, attackerForgedPop, rogueOwner), + "PoP signed under attackerSK must NOT validate as possession of pkRogue. "+ + "This is the exact property that blocks the rogue-key registration") + + // 5. Also reject if the attacker simply pairs the pubkey with a + // blank signature — defensive cover for an actor who skips the + // sign step entirely. + require.Error(VerifyBLSPop(pkRogue, make([]byte, crypto.BLSAggregateSignatureLength), rogueOwner), + "all-zeros signature must not validate") + + // 6. Control: a delegate that DOES know their key's secret can + // produce a valid PoP — the gate is not blanket-rejecting BLS + // registrations, only those without possession. + legitSK := blsKeyForTest(t, "honest-registrant") + legitPop, err := SignBLSPop(legitSK, rogueOwner) + require.NoError(err) + require.NoError(VerifyBLSPop(legitSK.PublicKey().Bytes(), legitPop, rogueOwner), + "control: a delegate that knows their own secret can register normally") +} diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index 8163308af4..df9b2db5cb 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -782,6 +782,14 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand c.Identifier = candID } if act.WithBLS() { + if featureCtx.EnforceBLSPoP { + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), owner); err != nil { + return log, nil, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + } c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateRegisteredEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) if err != nil { @@ -885,6 +893,14 @@ func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.Candid } if act.WithBLS() { + if featureCtx.EnforceBLSPoP { + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.Owner); err != nil { + return log, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + } c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateUpdatedEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) if err != nil { @@ -932,6 +948,14 @@ func (p *Protocol) handleCandidateUpdateByOperator(ctx context.Context, act *act failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } + if protocol.MustGetFeatureCtx(ctx).EnforceBLSPoP { + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.Owner); err != nil { + return log, &handleError{ + err: errors.Wrap(err, "BLS proof-of-possession invalid"), + failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, + } + } + } // update BLS public key c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateUpdatedEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) diff --git a/action/signedaction.go b/action/signedaction.go index f3c5d4cd85..96264dbc5e 100644 --- a/action/signedaction.go +++ b/action/signedaction.go @@ -99,12 +99,15 @@ func SignedCandidateRegister( } // SignedCandidateRegisterWithBLS returns a signed candidate register with BLS public key +// and proof-of-possession. blsPop may be empty for pre-fork callers and tests; the +// handler enforces non-empty PoP once EnforceBLSPoP is active. func SignedCandidateRegisterWithBLS( nonce uint64, name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr string, duration uint32, autoStake bool, blsPubKey []byte, + blsPop []byte, payload []byte, gasLimit uint64, gasPrice *big.Int, @@ -112,7 +115,7 @@ func SignedCandidateRegisterWithBLS( options ...SignedActionOption, ) (*SealedEnvelope, error) { cr, err := NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountStr, - duration, autoStake, blsPubKey, payload) + duration, autoStake, blsPubKey, blsPop, payload) if err != nil { return nil, err } @@ -162,16 +165,19 @@ func SignedCandidateUpdate( } // SignedCandidateUpdateWithBLS returns a signed candidate update with BLS public key +// and the corresponding proof-of-possession. blsPop may be empty for pre-fork +// callers; the handler enforces non-empty PoP once EnforceBLSPoP is active. func SignedCandidateUpdateWithBLS( nonce uint64, name, operatorAddrStr, rewardAddrStr string, blsPubKey []byte, + blsPop []byte, gasLimit uint64, gasPrice *big.Int, registererPriKey crypto.PrivateKey, options ...SignedActionOption, ) (*SealedEnvelope, error) { - cu, err := NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey) + cu, err := NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey, blsPop) if err != nil { return nil, err } diff --git a/api/web3server_integrity_test.go b/api/web3server_integrity_test.go index a4f5565671..3c3cde3964 100644 --- a/api/web3server_integrity_test.go +++ b/api/web3server_integrity_test.go @@ -688,6 +688,7 @@ func web3Staking(t *testing.T, handler *hTTPHandler) { "io1xpq62aw85uqzrccg9y5hnryv8ld2nkpycc3gza", "io1xpq62aw85uqzrccg9y5hnryv8ld2nkpycc3gza", blsPubKey, + nil, ) require.NoError(err) data9, err := act9.EthData() diff --git a/e2etest/native_staking_test.go b/e2etest/native_staking_test.go index 03a10ae2e2..0c141ee1d6 100644 --- a/e2etest/native_staking_test.go +++ b/e2etest/native_staking_test.go @@ -1631,7 +1631,7 @@ func TestCandidateBLSPublicKey(t *testing.T) { name: "register with bls key", preActs: genTransferActionsWithPrice(int(cfg.Genesis.XinguBlockHeight), gasPrice1559), acts: []*actionWithTime{ - {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(candOwnerID2).String()), "cand2", identityset.Address(candOperatorID2).String(), identityset.Address(2).String(), identityset.Address(candOwnerID2).String(), registerAmount.String(), 1, true, blsPubKey, []byte{1, 2, 3}, gasLimit, gasPrice, identityset.PrivateKey(candOwnerID2), action.WithChainID(chainID))), time.Now()}, + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(candOwnerID2).String()), "cand2", identityset.Address(candOperatorID2).String(), identityset.Address(2).String(), identityset.Address(candOwnerID2).String(), registerAmount.String(), 1, true, blsPubKey, nil, []byte{1, 2, 3}, gasLimit, gasPrice, identityset.PrivateKey(candOwnerID2), action.WithChainID(chainID))), time.Now()}, }, blockExpect: func(test *e2etest, blk *block.Block, err error) { require.NoError(err) @@ -1657,7 +1657,7 @@ func TestCandidateBLSPublicKey(t *testing.T) { name: "update bls key by operator", preActs: genTransferActionsWithPrice(jumps, gasPrice1559), acts: []*actionWithTime{ - {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(candOperatorID).String()), "cand1", identityset.Address(candOperatorID).String(), "", blsPrivKey2.PublicKey().Bytes(), gasLimit, gasPrice, identityset.PrivateKey(candOperatorID), action.WithChainID(chainID))), time.Now()}, + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(candOperatorID).String()), "cand1", identityset.Address(candOperatorID).String(), "", blsPrivKey2.PublicKey().Bytes(), nil, gasLimit, gasPrice, identityset.PrivateKey(candOperatorID), action.WithChainID(chainID))), time.Now()}, }, blockExpect: func(test *e2etest, blk *block.Block, err error) { require.NoError(err) diff --git a/go.mod b/go.mod index da789b389c..888ea6b3aa 100644 --- a/go.mod +++ b/go.mod @@ -353,3 +353,5 @@ replace github.com/ethereum/go-ethereum/crypto/secp256k1 => github.com/erigontec // Fix for go-libutp compatibility with GCC 15+ replace github.com/anacrolix/go-libutp => github.com/anacrolix/go-libutp v0.0.0-20251121015447-f294e5ed5b4d + +replace github.com/iotexproject/iotex-proto => github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d diff --git a/go.sum b/go.sum index 67e41bec79..36ce1c014d 100644 --- a/go.sum +++ b/go.sum @@ -299,6 +299,8 @@ github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a h1:BE2G github.com/envestcc/erigon/erigon-lib v0.0.0-20251229032433-18f245cc374a/go.mod h1:7LneN7BMglt3mEe6/ypXrq64LiXgvGcDvbJgEzZnEpI= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d h1:vPl7sjgea9uQFeGdUr1uhrtT2kDbtRozOC9QnhnrX5E= github.com/envestcc/go-verkle v0.0.0-20251216081422-a9d13963495d/go.mod h1:CFlPtIrMHxhNuguRY0fdyKPr6hvbFDfUd1q8YcOcoYE= +github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d h1:t3BKmnxqMQAPoooy6+QojL3eM+4aglbBTF0sHuipWzg= +github.com/envestcc/iotex-proto v0.0.0-20260610010006-7a486f6a453d/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -598,8 +600,6 @@ github.com/iotexproject/iotex-antenna-go/v2 v2.6.4 h1:7e0VyBDFT+iqwvr/BIk38yf7nC github.com/iotexproject/iotex-antenna-go/v2 v2.6.4/go.mod h1:L6AzDHo2TBFDAPA3ly+/PCS4JSX2g3zzhwV8RGQsTDI= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1 h1:jPLni/qKAnxv87HMCutde2tP9JmfWuLZgGpB4OArQGM= github.com/iotexproject/iotex-election v0.3.8-0.20251015031218-8df952babca1/go.mod h1:w9HriT1coMRbuknaSD2xqiOqDTnowBDzvFZv8tg1j2M= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16 h1:iaFjQ8QJ3ekZnwPlUX3XJHZ/5uf+FbUltH6o24d4NYA= -github.com/iotexproject/iotex-proto v0.6.6-0.20260211020747-f26bd969ed16/go.mod h1:OOXZIG6Q9tInog8Y5zzEJQsDv9IaG/xxpDtl4KzdWZs= github.com/ipfs/boxo v0.27.2 h1:sGo4KdwBaMjdBjH08lqPJyt27Z4CO6sugne3ryX513s= github.com/ipfs/boxo v0.27.2/go.mod h1:qEIRrGNr0bitDedTCzyzBHxzNWqYmyuHgK8LG9Q83EM= github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= diff --git a/ioctl/cmd/action/stake2register.go b/ioctl/cmd/action/stake2register.go index 34fc40532b..6b992f87f5 100644 --- a/ioctl/cmd/action/stake2register.go +++ b/ioctl/cmd/action/stake2register.go @@ -115,7 +115,11 @@ func register(args []string) error { if err != nil { return output.NewError(0, "failed to get nonce ", err) } - cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountInRau.String(), duration, _stake2AutoStake, blsPubKeyBytes, payload) + // TODO: derive blsPop from a user-supplied BLS private key and pass + // it here once the ioctl flow supports it. Pre-fork the PoP is + // optional; post-fork (EnforceBLSPoP active) the handler will reject + // the registration if blsPop is empty. + cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountInRau.String(), duration, _stake2AutoStake, blsPubKeyBytes, nil, payload) if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateRegister instance", err) diff --git a/ioctl/cmd/action/stake2update.go b/ioctl/cmd/action/stake2update.go index 9637798aaa..ba5775a830 100644 --- a/ioctl/cmd/action/stake2update.go +++ b/ioctl/cmd/action/stake2update.go @@ -91,7 +91,10 @@ func stake2Update(args []string) error { return output.NewError(0, "failed to get nonce ", err) } - s2u, err := action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKeyBytes) + // TODO: derive blsPop from a user-supplied BLS private key. Pre-fork + // the PoP is optional; post-fork (EnforceBLSPoP active) the handler + // rejects an update that rotates the BLS key without a PoP. + s2u, err := action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKeyBytes, nil) if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateUpdate instance", err) } From 4d538979241ba5c64c4875afb1b381c60d385f77 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 11 Jun 2026 11:37:36 +0800 Subject: [PATCH 2/9] fix(staking): reject duplicate BLS pubkey at candidate register/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the BLS proof-of-possession fix. Even with PoP in place, nothing in the current handler stops two different candidates from registering with the same blsPubKey: the existing ContainsName / ContainsOwner / ContainsOperator checks have no BLS equivalent. Two delegates sharing one BLS pubkey breaks IIP-52's quorum-counting model. The signer bitmap counts both delegates as having voted, but FastAggregateVerify aggregates pubkeys as a set — one contribution per distinct pubkey. The committee size as seen by aggregation is N, the committee size as seen by the bitmap is N+1, and the second delegate's stake-weight effectively "votes for free." This is a quieter cousin of the rogue-key attack: the aggregation math still verifies; only the accounting is off. Adds CandidateCenter.ContainsBLSPubKey(blsPubKey, except) and surfaces it through CandidateStateManager. Implementation is a linear scan over candidates — registration / update are sparse calls and the active delegate set is bounded; the saved O(1) lookup is not worth maintaining a fourth index map across the change/base commit flow. Hooks into all three handler paths under the EnforceBLSPoP gate: - handleCandidateRegister: reject if blsPubKey is held by any candidate other than the incumbent (when re-registering against an existing owner-without-selfstake record). - handleCandidateUpdate: reject if blsPubKey is held by any candidate other than the one being updated. A candidate keeping its own pubkey across updates is allowed (except = c.GetIdentifier()). - handleCandidateUpdateByOperator: same as update-by-owner. Failures return ReceiptStatus_ErrCandidateConflict (matches existing collision semantics for name / operator). Gated by EnforceBLSPoP so pre-fork blocks replay unchanged. Test: TestCandidateCenter_ContainsBLSPubKey covers nil / empty / no-match / match-with-nil-except / match-against-self (allowed) / match-against- other (rejected). Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/staking/candidate_center.go | 27 +++++++++++ .../protocol/staking/candidate_center_test.go | 46 +++++++++++++++++++ .../staking/candidate_statemanager.go | 11 +++++ action/protocol/staking/handlers.go | 30 ++++++++++++ 4 files changed, 114 insertions(+) diff --git a/action/protocol/staking/candidate_center.go b/action/protocol/staking/candidate_center.go index aac811c47c..54cdd0714d 100644 --- a/action/protocol/staking/candidate_center.go +++ b/action/protocol/staking/candidate_center.go @@ -198,6 +198,33 @@ func (m *CandidateCenter) ContainsOwner(owner address.Address) bool { return false } +// ContainsBLSPubKey reports whether any candidate other than the one +// identified by `except` holds the given BLS pubkey. A linear scan is +// used (candidate-center registration is rare and the active set is +// bounded), trading O(N) lookup for not having to maintain a new index +// map across the change/base commit flow. +func (m *CandidateCenter) ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool { + if len(blsPubKey) == 0 { + return false + } + exceptID := "" + if except != nil { + exceptID = except.String() + } + for _, d := range m.All() { + if len(d.BLSPubKey) == 0 { + continue + } + if d.GetIdentifier().String() == exceptID { + continue + } + if bytes.Equal(d.BLSPubKey, blsPubKey) { + return true + } + } + return false +} + // ContainsOperator returns true if the map contains the candidate by operator func (m *CandidateCenter) ContainsOperator(operator address.Address) bool { if operator == nil { diff --git a/action/protocol/staking/candidate_center_test.go b/action/protocol/staking/candidate_center_test.go index b2e930f0f2..812e21b160 100644 --- a/action/protocol/staking/candidate_center_test.go +++ b/action/protocol/staking/candidate_center_test.go @@ -645,3 +645,49 @@ func TestCandidateUpsert(t *testing.T) { r.Equal(cand, m.GetByIdentifier(cand.GetIdentifier())) }) } + +// TestCandidateCenter_ContainsBLSPubKey covers the uniqueness check used +// by handleCandidateRegister and handleCandidateUpdate to enforce one +// BLS pubkey per delegate — a precondition for IIP-52's quorum-counting +// model (FastAggregateVerify dedups pubkeys but the signer bitmap does +// not). +func TestCandidateCenter_ContainsBLSPubKey(t *testing.T) { + r := require.New(t) + c, err := NewCandidateCenter(nil) + r.NoError(err) + + pkA := []byte("dummy-bls-pubkey-A-48-bytes-pad-________________")[:48] + pkB := []byte("dummy-bls-pubkey-B-48-bytes-pad-________________")[:48] + + candA := &Candidate{ + Owner: identityset.Address(1), + Operator: identityset.Address(7), + Reward: identityset.Address(1), + Name: "cand-a", + Votes: big.NewInt(0), + SelfStake: big.NewInt(0), + SelfStakeBucketIdx: 0, + BLSPubKey: pkA, + } + r.NoError(c.Upsert(candA)) + r.NoError(c.commit()) + + // Empty / nil pubkey never collides. + r.False(c.ContainsBLSPubKey(nil, nil), "nil pubkey is not in use") + r.False(c.ContainsBLSPubKey([]byte{}, nil), "empty pubkey is not in use") + + // Unrelated pubkey does not collide. + r.False(c.ContainsBLSPubKey(pkB, nil), "unrelated pubkey is not in use") + + // pkA is in use by candA. + r.True(c.ContainsBLSPubKey(pkA, nil), + "pkA is registered; ContainsBLSPubKey(except=nil) must report true") + + // candA may re-register / update with the same pkA (no collision against itself). + r.False(c.ContainsBLSPubKey(pkA, candA.GetIdentifier()), + "a candidate must be allowed to keep its own BLS pubkey on update") + + // A different candidate must NOT be allowed to take pkA. + r.True(c.ContainsBLSPubKey(pkA, identityset.Address(2)), + "another candidate trying to take pkA must collide") +} diff --git a/action/protocol/staking/candidate_statemanager.go b/action/protocol/staking/candidate_statemanager.go index 99b48db7d0..7d61e90364 100644 --- a/action/protocol/staking/candidate_statemanager.go +++ b/action/protocol/staking/candidate_statemanager.go @@ -49,6 +49,13 @@ type ( ContainsName(string) bool ContainsOwner(address.Address) bool ContainsOperator(address.Address) bool + // ContainsBLSPubKey reports whether any candidate other than the + // one identified by `except` already holds the given BLS pubkey. + // `except` may be nil at registration time (no incumbent), in + // which case the check rejects on any match. Used to enforce one + // BLS pubkey per delegate, a hard requirement for IIP-52's + // FastAggregateVerify quorum-counting model. + ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool ContainsSelfStakingBucket(uint64) bool GetByName(string) *Candidate GetByOwner(address.Address) *Candidate @@ -139,6 +146,10 @@ func (csm *candSM) ContainsOperator(addr address.Address) bool { return csm.candCenter.ContainsOperator(addr) } +func (csm *candSM) ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool { + return csm.candCenter.ContainsBLSPubKey(blsPubKey, except) +} + func (csm *candSM) ContainsSelfStakingBucket(index uint64) bool { return csm.candCenter.ContainsSelfStakingBucket(index) } diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index df9b2db5cb..391685294b 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -740,6 +740,24 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, } } + // cannot collide with an existing BLS pubkey. Two delegates sharing + // a BLS pubkey break IIP-52's quorum-counting model: the signer + // bitmap would count both delegates, but FastAggregateVerify sums + // the pubkey set as a set (one contribution per distinct pubkey), + // producing an off-by-one mismatch that lets the second delegate's + // stake-weight "vote for free". + if act.WithBLS() && featureCtx.EnforceBLSPoP { + var except address.Address + if ownerExist { + except = c.GetIdentifier() + } + if csm.ContainsBLSPubKey(act.BLSPubKey(), except) { + return log, nil, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } + } var ( bucketIdx uint64 @@ -900,6 +918,12 @@ func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.Candid failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } + if csm.ContainsBLSPubKey(act.BLSPubKey(), c.GetIdentifier()) { + return log, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } } c.BLSPubKey = act.BLSPubKey() topics, eventData, err := action.PackCandidateUpdatedEvent(c.GetIdentifier(), c.Operator, c.Owner, c.Name, c.Reward, act.BLSPubKey()) @@ -955,6 +979,12 @@ func (p *Protocol) handleCandidateUpdateByOperator(ctx context.Context, act *act failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } + if csm.ContainsBLSPubKey(act.BLSPubKey(), c.GetIdentifier()) { + return log, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } + } } // update BLS public key c.BLSPubKey = act.BLSPubKey() From 3fbaa0dcd52dc62c4e49f955659878aa0d6cff39 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 11 Jun 2026 12:13:14 +0800 Subject: [PATCH 3/9] refactor(staking): bind update-path BLS PoP to identifier, not current owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoP at candidate update was binding to c.Owner — the *current* owner. For post-Xingu candidates this drifts after CandidateTransferOwnership: the new owner has to know they're the new owner at signing time, and a PoP signed under the old owner during a concurrent transfer is rejected purely on a value mismatch that has nothing to do with key possession. c.GetIdentifier() is the stable handle for this purpose: - Post-Xingu non-collision (the common case): Identifier is set once at register time to the original owner address (generateCandidateID fast-paths to owner when it's free). So a PoP signed at register binding to owner == identifier, and a PoP signed at update binding to c.GetIdentifier() lines up with that same value, even if owner has since transferred. - Post-Xingu collision (edge case): Identifier is a hash-derived address. Register still binds to act.OwnerAddress() (signer can't predict the hash), updates use c.GetIdentifier(). The register-time PoP doesn't get re-verified later, so this asymmetry is harmless; inside the candidate's lifetime, all update PoPs use the same hash consistently. - Pre-Xingu: c.Identifier is nil and c.GetIdentifier() falls back to c.Owner — behavior identical to the prior code. Renamed the function parameter from ownerAddress to candidateID throughout (BLSPopSigningRoot, SignBLSPop, VerifyBLSPop) and updated the docstring to spell out what to pass at register vs update. The on-the-wire bytes are unchanged — only the conceptual binding shifts. Tests: renamed TestBLSPop_RejectWrongOwner to RejectWrongCandidateID; added TestBLSPop_StableAcrossOwnershipTransfer locking in the property that a PoP signed under the original owner still verifies under the same identifier after the candidate is transferred to a new owner. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/staking/bls_pop.go | 40 ++++++++++++------ action/protocol/staking/bls_pop_test.go | 54 ++++++++++++++++++++----- action/protocol/staking/handlers.go | 11 ++++- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/action/protocol/staking/bls_pop.go b/action/protocol/staking/bls_pop.go index e3d89a1525..b9ad97dfcb 100644 --- a/action/protocol/staking/bls_pop.go +++ b/action/protocol/staking/bls_pop.go @@ -30,14 +30,14 @@ const blsPopDomain = "IOTEX_BLS_POP_v1" // must be computed over for the given candidate. // // Binding three values into the signed message — the domain tag, the BLS -// public key itself, and the candidate owner address — closes the rogue -// key attack and two related replays: +// public key itself, and the candidate's identity address — closes the +// rogue key attack and two related replays: // // - blsPubKey: forces the signer to know the private key for THIS // specific BLS pubkey. A rogue pubkey constructed as // g^x − Σ(other pubkeys) cannot produce a valid PoP because the // attacker does not know its discrete log. -// - ownerAddress: prevents two distinct candidates from sharing a +// - candidateID: prevents two distinct candidates from sharing a // single BLS keypair (and thus a single PoP) without each owner // independently re-attesting; also prevents a PoP submitted for // candidate A from being replayed for candidate B by a man-in-the- @@ -45,31 +45,47 @@ const blsPopDomain = "IOTEX_BLS_POP_v1" // - blsPopDomain: keeps PoP signatures disjoint from consensus // signatures, future PoP schemes, and any other BLS-signed iotex // message that may exist or be added later. -func BLSPopSigningRoot(blsPubKey []byte, ownerAddress address.Address) []byte { +// +// candidateID is the candidate's stable identity: +// - At register: the owner address declared in the action. For +// non-collision registrations this becomes c.Identifier verbatim +// (see generateCandidateID — it returns owner directly when free), +// so a PoP signed at register time matches the identifier the +// candidate will carry post-fork. +// - At update: c.GetIdentifier(), which returns the immutable +// Identifier for post-Xingu candidates and falls back to c.Owner +// for pre-Xingu records. This means a candidate that has been +// transferred to a new owner still uses its original identity for +// PoP, so the binding is stable across ownership transfers. +func BLSPopSigningRoot(blsPubKey []byte, candidateID address.Address) []byte { h := sha256.New() h.Write([]byte(blsPopDomain)) h.Write(blsPubKey) - if ownerAddress != nil { - h.Write(ownerAddress.Bytes()) + if candidateID != nil { + h.Write(candidateID.Bytes()) } return h.Sum(nil) } // SignBLSPop produces a proof-of-possession for the given BLS private -// key, binding it to the candidate owner address. Used by tooling +// key, binding it to the candidate's identity. Used by tooling // (ioctl, SDK) to generate the bls_pop field on CandidateRegister / // CandidateUpdate transactions. -func SignBLSPop(sk *crypto.BLS12381PrivateKey, ownerAddress address.Address) ([]byte, error) { +// +// At registration time pass the proposed owner address (which becomes +// the candidate identifier); at update time pass the candidate's +// existing identifier (c.GetIdentifier()). +func SignBLSPop(sk *crypto.BLS12381PrivateKey, candidateID address.Address) ([]byte, error) { if sk == nil { return nil, errors.New("nil BLS private key") } pk := sk.PublicKey().Bytes() - return sk.Sign(BLSPopSigningRoot(pk, ownerAddress)) + return sk.Sign(BLSPopSigningRoot(pk, candidateID)) } // VerifyBLSPop verifies the proof-of-possession against the provided -// pubkey and owner. Returns nil on success. -func VerifyBLSPop(blsPubKey, blsPop []byte, ownerAddress address.Address) error { +// pubkey and candidate identity. Returns nil on success. +func VerifyBLSPop(blsPubKey, blsPop []byte, candidateID address.Address) error { if len(blsPubKey) != crypto.BLSPubkeyLength { return errors.Errorf("invalid BLS pubkey length: got %d, want %d", len(blsPubKey), crypto.BLSPubkeyLength) } @@ -80,7 +96,7 @@ func VerifyBLSPop(blsPubKey, blsPop []byte, ownerAddress address.Address) error if err != nil { return errors.Wrap(err, "invalid BLS pubkey") } - if !pk.Verify(BLSPopSigningRoot(blsPubKey, ownerAddress), blsPop) { + if !pk.Verify(BLSPopSigningRoot(blsPubKey, candidateID), blsPop) { return errors.New("BLS proof-of-possession verification failed") } return nil diff --git a/action/protocol/staking/bls_pop_test.go b/action/protocol/staking/bls_pop_test.go index 0009449038..48628eb2fa 100644 --- a/action/protocol/staking/bls_pop_test.go +++ b/action/protocol/staking/bls_pop_test.go @@ -56,21 +56,53 @@ func TestBLSPop_RejectInvalidLength(t *testing.T) { "short PoP rejected") } -func TestBLSPop_RejectWrongOwner(t *testing.T) { - // A PoP issued for one candidate owner must not verify under another - // owner — closes the replay window where an attacker repackages a - // CandidateRegister tx with a different owner address. +func TestBLSPop_RejectWrongCandidateID(t *testing.T) { + // A PoP issued for one candidate must not verify under a different + // candidate ID — closes the replay window where an attacker + // repackages a CandidateRegister tx with a different identity. require := require.New(t) sk := blsKeyForTest(t, "delegate") - ownerA := addrForTest(t, "owner-A") - ownerB := addrForTest(t, "owner-B") + candA := addrForTest(t, "candidate-A") + candB := addrForTest(t, "candidate-B") - pop, err := SignBLSPop(sk, ownerA) + pop, err := SignBLSPop(sk, candA) require.NoError(err) - require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, ownerA), - "sanity: PoP verifies for the owner it was signed for") - require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), pop, ownerB), - "PoP must NOT verify under a different owner") + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, candA), + "sanity: PoP verifies for the candidate it was signed for") + require.Error(VerifyBLSPop(sk.PublicKey().Bytes(), pop, candB), + "PoP must NOT verify under a different candidate ID") +} + +// TestBLSPop_StableAcrossOwnershipTransfer locks in the property that +// motivated switching update-path PoP binding from c.Owner to +// c.GetIdentifier(): a PoP signed at registration (bound to the +// original owner = future identifier in the non-collision case) MUST +// still verify when the same identifier is the binding at update time +// — even if the candidate's current owner has changed via +// CandidateTransferOwnership. +// +// The test models this by signing once with candidateID = original +// owner, then verifying with the same identifier even though the +// "current owner" in the surrounding state (not modeled here, but +// implicit) would be different. +func TestBLSPop_StableAcrossOwnershipTransfer(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "delegate") + originalOwner := addrForTest(t, "original-owner") + + // Sign once at registration time, binding to the original owner. + // For post-Xingu non-collision candidates this becomes c.Identifier + // verbatim (generateCandidateID returns owner when free). + pop, err := SignBLSPop(sk, originalOwner) + require.NoError(err) + + // Later, the candidate is transferred (originalOwner → newOwner) and + // the same delegate submits a BLS-related update. The handler now + // passes c.GetIdentifier() — which is still originalOwner — to + // VerifyBLSPop. The same PoP must still validate. + require.NoError(VerifyBLSPop(sk.PublicKey().Bytes(), pop, originalOwner), + "PoP signed under the original owner / identifier must validate "+ + "unchanged when the candidate's owner has been transferred") } func TestBLSPop_RejectWrongPubkey(t *testing.T) { diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index 391685294b..03b35513c0 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -912,7 +912,12 @@ func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.Candid if act.WithBLS() { if featureCtx.EnforceBLSPoP { - if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.Owner); err != nil { + // PoP binds to the candidate's stable identity, not the + // current owner — for post-Xingu candidates this stays + // constant across CandidateTransferOwnership; for pre-Xingu + // GetIdentifier falls back to c.Owner so behavior is + // unchanged from owner-binding. + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.GetIdentifier()); err != nil { return log, &handleError{ err: errors.Wrap(err, "BLS proof-of-possession invalid"), failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, @@ -973,7 +978,9 @@ func (p *Protocol) handleCandidateUpdateByOperator(ctx context.Context, act *act } } if protocol.MustGetFeatureCtx(ctx).EnforceBLSPoP { - if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.Owner); err != nil { + // PoP binds to the candidate's stable identity (see the + // owner-path handler above for rationale). + if err := VerifyBLSPop(act.BLSPubKey(), act.BLSPop(), c.GetIdentifier()); err != nil { return log, &handleError{ err: errors.Wrap(err, "BLS proof-of-possession invalid"), failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, From ab96bbc4bdb57eb70f49097dfa70b4155593643d Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 16 Jun 2026 09:13:48 +0800 Subject: [PATCH 4/9] feat(action): web3 ABI methods for BLS register/update with PoP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PoP coverage in the Go action layer (envestcc/iotex-core#4854) wasn't visible from the web3 path: candidateRegisterWithBLS / candidateUpdateWithBLS ABI entries have no blsPop slot, so any tx routed through eth_sendRawTransaction and decoded via NewCandidateRegister/UpdateFromABIBinary silently dropped the field. Post-fork that would have produced txs the handler always rejects. Adds two new V2 ABI methods rather than mutating the existing selectors: - candidateRegisterWithBLSAndPoP (8 fields + blsPop + data) - candidateUpdateWithBLSAndPoP (4 fields + blsPubKey + blsPop) Why V2 instead of extending the existing methods: changing a parameter list changes the 4-byte function selector, breaking any tooling that hardcoded the old ABI. The legacy WithBLS entries stay working pre-fork (their handler path is unchanged), and post-fork they reject naturally for lacking PoP — coexistence with no selector churn. EthData routing picks the entry by data carried on the action: - WithBLS && len(blsPop) > 0 → V2 selector, calldata includes PoP - WithBLS → legacy selector, no PoP slot - otherwise → non-BLS legacy candidateRegister FromABIBinary recognises the V2 selector and decodes blsPop. Tests: extended TestCandidateRegisterABIEncodeAndDecode with a "with public key and PoP" subcase, and added TestCandidateUpdate / "ABI encode with PoP" — both confirm the codec round-trips a non-empty PoP through Pack + Unpack, locking in the property whose absence was the bug. Out of scope for this commit: e2e coverage that exercises the web3 path through to a post-fork handler (proves the new selector actually flows blsPop into Candidate.BLSPubKey assignment). Will follow once the EnforceBLSPoP gate gets switched on in an integration scenario. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/candidate_register.go | 48 ++++++++++++++ action/candidate_update.go | 36 +++++++++++ action/candidateregister_test.go | 18 ++++++ action/candidateupdate_test.go | 22 +++++++ action/native_staking_contract_abi.json | 86 +++++++++++++++++++++++++ 5 files changed, 210 insertions(+) diff --git a/action/candidate_register.go b/action/candidate_register.go index 66ddb88ce5..5950bf2fc6 100644 --- a/action/candidate_register.go +++ b/action/candidate_register.go @@ -32,6 +32,15 @@ var ( // _candidateRegisterInterface is the interface of the abi encoding of stake action _candidateRegisterMethod abi.Method _candidateRegisterWithBLSMethod abi.Method + // _candidateRegisterWithBLSAndPoPMethod is the V2 ABI entry that adds the + // BLS proof-of-possession parameter alongside the existing WithBLS fields. + // Required post-fork (EnforceBLSPoP gate) because the handler rejects + // registrations whose blsPop is missing. The pre-fork WithBLS method is + // retained so existing tooling that has not yet adopted PoP continues to + // compile registrations — those txs will still be rejected at the handler + // post-fork, but coexistence avoids breaking the function-selector ID of + // the legacy method for any client tracking it. + _candidateRegisterWithBLSAndPoPMethod abi.Method _candidateRegisteredEvent abi.Event _stakedEvent abi.Event _candidateActivatedEvent abi.Event @@ -85,6 +94,10 @@ func init() { if !ok { panic("fail to load the method") } + _candidateRegisterWithBLSAndPoPMethod, ok = abi.Methods["candidateRegisterWithBLSAndPoP"] + if !ok { + panic("fail to load the candidateRegisterWithBLSAndPoP method") + } _candidateRegisteredEvent, ok = abi.Events["CandidateRegistered"] if !ok { panic("fail to load the event") @@ -375,7 +388,27 @@ func (cr *CandidateRegister) EthData() ([]byte, error) { return nil, ErrAddress } switch { + case cr.WithBLS() && len(cr.blsPop) > 0: + // Post-fork path: blsPop is required, encode with the V2 method so + // the function-selector ID committed in the calldata signals + // "PoP-carrying registration" to all decoders. + data, err := _candidateRegisterWithBLSAndPoPMethod.Inputs.Pack( + cr.name, + common.BytesToAddress(cr.operatorAddress.Bytes()), + common.BytesToAddress(cr.rewardAddress.Bytes()), + common.BytesToAddress(cr.ownerAddress.Bytes()), + cr.duration, + cr.autoStake, + cr.blsPubKey, + cr.blsPop, + cr.payload) + if err != nil { + return nil, err + } + return append(_candidateRegisterWithBLSAndPoPMethod.ID, data...), nil case cr.WithBLS(): + // Legacy WithBLS without PoP. Pre-fork still works; post-fork the + // handler rejects this for lacking proof-of-possession. data, err := _candidateRegisterWithBLSMethod.Inputs.Pack( cr.name, common.BytesToAddress(cr.operatorAddress.Bytes()), @@ -519,12 +552,17 @@ func NewCandidateRegisterFromABIBinary(data []byte, value *big.Int) (*CandidateR if len(data) <= 4 { return nil, errDecodeFailure } + withPoP := false switch { case bytes.Equal(_candidateRegisterMethod.ID, data[:4]): method = _candidateRegisterMethod case bytes.Equal(_candidateRegisterWithBLSMethod.ID, data[:4]): method = _candidateRegisterWithBLSMethod withBLS = true + case bytes.Equal(_candidateRegisterWithBLSAndPoPMethod.ID, data[:4]): + method = _candidateRegisterWithBLSAndPoPMethod + withBLS = true + withPoP = true default: return nil, errDecodeFailure } @@ -568,6 +606,16 @@ func NewCandidateRegisterFromABIBinary(data []byte, value *big.Int) (*CandidateR if err != nil { return nil, errors.Wrap(err, "failed to parse BLS public key") } + if withPoP { + pop, ok := paramsMap["blsPop"].([]byte) + if !ok { + return nil, errors.Wrapf(errDecodeFailure, "invalid blsPop %+v", paramsMap["blsPop"]) + } + if len(pop) == 0 { + return nil, errors.Wrap(errDecodeFailure, "blsPop is empty") + } + cr.blsPop = pop + } } else { if cr.amount, ok = paramsMap["amount"].(*big.Int); !ok { return nil, errDecodeFailure diff --git a/action/candidate_update.go b/action/candidate_update.go index 2c885638e8..1835563849 100644 --- a/action/candidate_update.go +++ b/action/candidate_update.go @@ -30,6 +30,11 @@ var ( // _candidateUpdateMethod is the interface of the abi encoding of stake action _candidateUpdateMethod abi.Method _candidateUpdateWithBLSMethod abi.Method + // _candidateUpdateWithBLSAndPoPMethod is the V2 ABI entry that adds the + // BLS proof-of-possession parameter alongside the existing WithBLS + // fields. Required post-fork (EnforceBLSPoP gate) — the handler rejects + // updates that rotate the blsPubKey without a fresh PoP. + _candidateUpdateWithBLSAndPoPMethod abi.Method _candidateUpdateWithBLSEvent abi.Event _ EthCompatibleAction = (*CandidateUpdate)(nil) ) @@ -74,6 +79,10 @@ func init() { if !ok { panic("fail to load the method") } + _candidateUpdateWithBLSAndPoPMethod, ok = NativeStakingContractABI().Methods["candidateUpdateWithBLSAndPoP"] + if !ok { + panic("fail to load the candidateUpdateWithBLSAndPoP method") + } _candidateUpdateWithBLSEvent, ok = NativeStakingContractABI().Events["CandidateUpdated"] if !ok { panic("fail to load the event") @@ -240,7 +249,19 @@ func (cu *CandidateUpdate) EthData() ([]byte, error) { return nil, ErrAddress } switch { + case cu.WithBLS() && len(cu.blsPop) > 0: + // Post-fork path: rotate the BLS pubkey with a fresh PoP. V2 + // selector signals the calldata carries proof-of-possession. + data, err := _candidateUpdateWithBLSAndPoPMethod.Inputs.Pack(cu.name, + common.BytesToAddress(cu.operatorAddress.Bytes()), + common.BytesToAddress(cu.rewardAddress.Bytes()), cu.blsPubKey, cu.blsPop) + if err != nil { + return nil, err + } + return append(_candidateUpdateWithBLSAndPoPMethod.ID, data...), nil case cu.WithBLS(): + // Legacy WithBLS without PoP — works pre-fork; post-fork the + // handler rejects this for lacking proof-of-possession. data, err := _candidateUpdateWithBLSMethod.Inputs.Pack(cu.name, common.BytesToAddress(cu.operatorAddress.Bytes()), common.BytesToAddress(cu.rewardAddress.Bytes()), cu.blsPubKey) @@ -273,12 +294,17 @@ func NewCandidateUpdateFromABIBinary(data []byte) (*CandidateUpdate, error) { if len(data) <= 4 { return nil, errDecodeFailure } + withPoP := false switch { case bytes.Equal(_candidateUpdateMethod.ID, data[:4]): method = &_candidateUpdateMethod case bytes.Equal(_candidateUpdateWithBLSMethod.ID, data[:4]): method = &_candidateUpdateWithBLSMethod withBLS = true + case bytes.Equal(_candidateUpdateWithBLSAndPoPMethod.ID, data[:4]): + method = &_candidateUpdateWithBLSAndPoPMethod + withBLS = true + withPoP = true default: return nil, errors.Wrapf(errDecodeFailure, "unknown method prefix %x", data[:4]) } @@ -305,6 +331,16 @@ func NewCandidateUpdateFromABIBinary(data []byte) (*CandidateUpdate, error) { if err != nil { return nil, errors.Wrap(err, "failed to parse BLS public key") } + if withPoP { + pop, ok := paramsMap["blsPop"].([]byte) + if !ok { + return nil, errors.Wrapf(errDecodeFailure, "blsPop is not []byte: %v", paramsMap["blsPop"]) + } + if len(pop) == 0 { + return nil, errors.Wrap(errDecodeFailure, "blsPop is empty") + } + cu.blsPop = pop + } } return &cu, nil } diff --git a/action/candidateregister_test.go b/action/candidateregister_test.go index fb27b4e4d3..b47daf7c4b 100644 --- a/action/candidateregister_test.go +++ b/action/candidateregister_test.go @@ -167,6 +167,8 @@ func TestCandidateRegisterABIEncodeAndDecode(t *testing.T) { require.Equal(test.AutoStake, stake.AutoStake()) if stake.WithBLS() { require.Equal(input.BLSPubKey(), stake.BLSPubKey()) + require.Equal(input.BLSPop(), stake.BLSPop(), + "PoP must round-trip through the ABI encode/decode path") } else { require.Equal(test.AmountStr, stake.Amount().String()) } @@ -194,6 +196,22 @@ func TestCandidateRegisterABIEncodeAndDecode(t *testing.T) { require.NoError(err) encode(stake) }) + t.Run("with public key and PoP", func(t *testing.T) { + // V2 selector path: ensure the candidateRegisterWithBLSAndPoP ABI + // entry actually round-trips the blsPop field through Pack / + // Unpack. Catches the bug envestcc flagged where the web3 path + // silently dropped PoP. + pk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(0).Bytes()) + require.NoError(err) + pop := make([]byte, crypto.BLSAggregateSignatureLength) + for i := range pop { + pop[i] = byte(i + 1) // any non-empty bytes; we're only testing the codec here + } + stake, err := NewCandidateRegisterWithBLS(test.Name, test.OperatorAddrStr, test.RewardAddrStr, test.OwnerAddrStr, test.AmountStr, test.Duration, test.AutoStake, pk.PublicKey().Bytes(), pop, test.Payload) + require.NoError(err) + require.Equal(pop, stake.BLSPop()) + encode(stake) + }) } diff --git a/action/candidateupdate_test.go b/action/candidateupdate_test.go index 3d66b92c2b..4523c9bb67 100644 --- a/action/candidateupdate_test.go +++ b/action/candidateupdate_test.go @@ -87,4 +87,26 @@ func TestCandidateUpdate(t *testing.T) { _, err = cu.EthData() require.Equal(ErrAddress, err) }) + t.Run("ABI encode with PoP", func(t *testing.T) { + // V2 selector path: round-trip blsPop through the + // candidateUpdateWithBLSAndPoP ABI entry. Guards against + // re-occurrence of the web3-path bug where the PoP field was + // silently dropped because the ABI method had no slot for it. + pop := make([]byte, crypto.BLSAggregateSignatureLength) + for i := range pop { + pop[i] = byte(i + 1) + } + cuWithPoP, err := NewCandidateUpdateWithBLS(_cuName, _cuOperatorAddrStr, _cuRewardAddrStr, blsPrivKey.PublicKey().Bytes(), pop) + require.NoError(err) + data, err := cuWithPoP.EthData() + require.NoError(err) + decoded, err := NewCandidateUpdateFromABIBinary(data) + require.NoError(err) + require.Equal(_cuName, decoded.Name()) + require.Equal(_cuOperatorAddrStr, decoded.OperatorAddress().String()) + require.Equal(_cuRewardAddrStr, decoded.RewardAddress().String()) + require.Equal(blsPrivKey.PublicKey().Bytes(), decoded.BLSPubKey()) + require.Equal(pop, decoded.BLSPop(), + "PoP must round-trip through the V2 ABI codec") + }) } diff --git a/action/native_staking_contract_abi.json b/action/native_staking_contract_abi.json index 51cf768df7..37569296ec 100644 --- a/action/native_staking_contract_abi.json +++ b/action/native_staking_contract_abi.json @@ -326,6 +326,59 @@ "stateMutability": "payable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "address", + "name": "operatorAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "rewardAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "ownerAddress", + "type": "address" + }, + { + "internalType": "uint32", + "name": "duration", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "autoStake", + "type": "bool" + }, + { + "internalType": "bytes", + "name": "blsPubKey", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "blsPop", + "type": "bytes" + }, + { + "internalType": "uint8[]", + "name": "data", + "type": "uint8[]" + } + ], + "name": "candidateRegisterWithBLSAndPoP", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, { "inputs": [ { @@ -395,6 +448,39 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "address", + "name": "operatorAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "rewardAddress", + "type": "address" + }, + { + "internalType": "bytes", + "name": "blsPubKey", + "type": "bytes" + }, + { + "internalType": "bytes", + "name": "blsPop", + "type": "bytes" + } + ], + "name": "candidateUpdateWithBLSAndPoP", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { From 98bdaa969c61af5ec218feb9a7231f59d80c49ce Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 16 Jun 2026 09:54:44 +0800 Subject: [PATCH 5/9] docs(action): mirror PoP ABI methods in the .sol interface + sync warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added candidateRegisterWithBLSAndPoP / candidateUpdateWithBLSAndPoP entries to native_staking_contract_abi.json but left native_staking_contract_interface.sol — the human-readable source-of-truth that clients / docs consume — out of sync. Adds the matching Solidity declarations and a header comment in both the .sol file and native_staking_contract_abi.go pointing out the non-obvious sharp edge: there is no Makefile target that regenerates the JSON from the .sol, so the two files must be kept in sync by hand on every ABI change. Out-of-sync edits silently misencode the web3 path — clients embed one function signature while the node decodes another. No runtime behaviour change; the JSON has been authoritative all along. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/native_staking_contract_abi.go | 9 +++++ action/native_staking_contract_interface.sol | 41 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/action/native_staking_contract_abi.go b/action/native_staking_contract_abi.go index 529e32d827..308ae83152 100644 --- a/action/native_staking_contract_abi.go +++ b/action/native_staking_contract_abi.go @@ -8,6 +8,15 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" ) +// IMPORTANT: native_staking_contract_abi.json and +// native_staking_contract_interface.sol are NOT auto-generated from each +// other. The JSON is what iotex-core actually loads at runtime; the .sol +// file is the human-readable source of truth for clients / docs. Adding a +// new method, parameter, or event requires editing BOTH files by hand, +// otherwise the web3 path silently misencodes: clients embed one signature +// while the node decodes another. There is no Makefile target that catches +// the drift — keep an eye on it during review. + var ( // NativeStakingContractJSONABI is the JSON ABI of the native staking contract //go:embed native_staking_contract_abi.json diff --git a/action/native_staking_contract_interface.sol b/action/native_staking_contract_interface.sol index 2941bd15a2..7385f30656 100644 --- a/action/native_staking_contract_interface.sol +++ b/action/native_staking_contract_interface.sol @@ -1,6 +1,14 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.24; +// NOTE: this Solidity interface is the human-readable source-of-truth for +// the staking ABI surface. Its companion file native_staking_contract_abi.json +// is the runtime ABI that iotex-core loads via NativeStakingContractABI(). +// The two are NOT auto-generated from each other; any new method, parameter, +// or event added here must be added by hand to the JSON as well (and the +// reverse). Out-of-sync edits silently break the web3 path — clients see one +// signature while the node decodes another. + interface INativeStakingContract { // Events event CandidateRegistered( @@ -64,6 +72,27 @@ interface INativeStakingContract { uint8[] memory data ) external payable; + // candidateRegisterWithBLSAndPoP adds the BLS proof-of-possession + // (blsPop) required post-fork by the BLS Producer Identity follow-up + // to IIP-52. The PoP is a 96-byte BLS signature over a domain-tagged + // hash that binds the registrant to their blsPubKey + candidate + // identity; without it the handler rejects the registration because + // IIP-52's FastAggregateVerify path is vulnerable to a rogue-key + // aggregate-forgery attack against un-attested keys. The legacy + // candidateRegisterWithBLS entry above is kept so existing tooling + // doesn't break, but its calldata is rejected post-fork. + function candidateRegisterWithBLSAndPoP( + string memory name, + address operatorAddress, + address rewardAddress, + address ownerAddress, + uint32 duration, + bool autoStake, + bytes memory blsPubKey, + bytes memory blsPop, + uint8[] memory data + ) external payable; + function candidateActivate(uint64 bucketIndex) external; // Candidate Deactivate methods @@ -102,6 +131,18 @@ interface INativeStakingContract { bytes memory blsPubKey ) external; + // candidateUpdateWithBLSAndPoP — the V2 counterpart that carries the + // BLS proof-of-possession needed post-fork when rotating the BLS + // pubkey (see candidateRegisterWithBLSAndPoP above for the + // motivation). + function candidateUpdateWithBLSAndPoP( + string memory name, + address operatorAddress, + address rewardAddress, + bytes memory blsPubKey, + bytes memory blsPop + ) external; + // Stake Management function depositToStake( uint64 bucketIndex, From 9229438acc7f565845abd31899d24aec1c5d9f24 Mon Sep 17 00:00:00 2001 From: envestcc Date: Tue, 16 Jun 2026 10:28:30 +0800 Subject: [PATCH 6/9] refactor(staking): GetByBLSPubKey + strict nil-candidateID PoP contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review threads on PR #4854: ## Comments 1 + 5: drop the `except` parameter Both envestcc and CoderZhi flagged that ContainsBLSPubKey(pubkey, except) bool pushed the "is this me?" decision into a generic helper that doesn't belong there. Replaces ContainsBLSPubKey(pubkey, except) bool with the broader GetByBLSPubKey(pubkey) *Candidate: callers receive the (possibly nil) holder and compare identifiers themselves. The three register / update handler call sites now read if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil && holder.GetIdentifier().String() != c.GetIdentifier().String() { return ErrCandidateConflict } which is more explicit than the prior `except`-hiding API. It also unifies with the same GetByBLSPubKey method added in #4857 (Y4a) so the two PRs don't introduce parallel BLS-pubkey-lookup APIs. ## Comments 2 + 4: strict nil-candidateID contract Both BLSPopSigningRoot and the surrounding Sign / Verify helpers used to silently accept a nil candidateID by skipping the candidate-binding write. That degrades the scheme to domain+pubkey-only — exactly the shape an attacker reaching for a cross-candidate replay would hope for. The three entry points now refuse: - BLSPopSigningRoot returns nil - SignBLSPop returns error("nil candidate ID; PoP must bind to a candidate identity") - VerifyBLSPop rejects before any cryptographic work TestBLSPop_RejectNilCandidateID locks the contract in place. ## Not in this commit Comment 3 ("blsPubKey can be removed from the signing root") — deferred. Will reply on the thread; the short answer is that the IRTF BLS draft defines canonical PoP as sign(sk, pk) and dropping blsPubKey from the digest opens up same-message aggregation when owner-uniqueness is ever relaxed. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/staking/bls_pop.go | 18 ++++++-- action/protocol/staking/bls_pop_test.go | 26 ++++++++++++ action/protocol/staking/candidate_center.go | 26 +++++------- .../protocol/staking/candidate_center_test.go | 42 +++++++++---------- .../staking/candidate_statemanager.go | 18 ++++---- action/protocol/staking/handlers.go | 23 +++++----- 6 files changed, 92 insertions(+), 61 deletions(-) diff --git a/action/protocol/staking/bls_pop.go b/action/protocol/staking/bls_pop.go index b9ad97dfcb..e87b2e7ea6 100644 --- a/action/protocol/staking/bls_pop.go +++ b/action/protocol/staking/bls_pop.go @@ -58,12 +58,18 @@ const blsPopDomain = "IOTEX_BLS_POP_v1" // transferred to a new owner still uses its original identity for // PoP, so the binding is stable across ownership transfers. func BLSPopSigningRoot(blsPubKey []byte, candidateID address.Address) []byte { + // Refuse to produce an "unbound" root. Allowing nil candidateID to + // silently fall through to a domain+pubkey-only digest would + // degrade the scheme to the weakest of its three bindings — + // exactly what an attacker reaching for a cross-candidate replay + // would hope for. Force callers to commit to a candidate identity. + if candidateID == nil { + return nil + } h := sha256.New() h.Write([]byte(blsPopDomain)) h.Write(blsPubKey) - if candidateID != nil { - h.Write(candidateID.Bytes()) - } + h.Write(candidateID.Bytes()) return h.Sum(nil) } @@ -79,6 +85,9 @@ func SignBLSPop(sk *crypto.BLS12381PrivateKey, candidateID address.Address) ([]b if sk == nil { return nil, errors.New("nil BLS private key") } + if candidateID == nil { + return nil, errors.New("nil candidate ID; PoP must bind to a candidate identity") + } pk := sk.PublicKey().Bytes() return sk.Sign(BLSPopSigningRoot(pk, candidateID)) } @@ -92,6 +101,9 @@ func VerifyBLSPop(blsPubKey, blsPop []byte, candidateID address.Address) error { if len(blsPop) != crypto.BLSAggregateSignatureLength { return errors.Errorf("invalid BLS PoP length: got %d, want %d", len(blsPop), crypto.BLSAggregateSignatureLength) } + if candidateID == nil { + return errors.New("nil candidate ID; PoP must bind to a candidate identity") + } pk, err := crypto.BLS12381PublicKeyFromBytes(blsPubKey) if err != nil { return errors.Wrap(err, "invalid BLS pubkey") diff --git a/action/protocol/staking/bls_pop_test.go b/action/protocol/staking/bls_pop_test.go index 48628eb2fa..247ef846a2 100644 --- a/action/protocol/staking/bls_pop_test.go +++ b/action/protocol/staking/bls_pop_test.go @@ -203,3 +203,29 @@ func TestBLSPop_RogueKeyAttackBlocked(t *testing.T) { require.NoError(VerifyBLSPop(legitSK.PublicKey().Bytes(), legitPop, rogueOwner), "control: a delegate that knows their own secret can register normally") } + +// TestBLSPop_RejectNilCandidateID locks in the contract that the three +// PoP entry points refuse to operate without a candidate-identity +// binding. Allowing nil candidateID to silently fall through would +// degrade the scheme to a domain+pubkey-only digest — exactly the +// shape an attacker reaching for a cross-candidate replay would hope +// for. Force callers to commit to an identity. +func TestBLSPop_RejectNilCandidateID(t *testing.T) { + require := require.New(t) + sk := blsKeyForTest(t, "any-delegate") + pk := sk.PublicKey().Bytes() + + // BLSPopSigningRoot returns nil. + require.Nil(BLSPopSigningRoot(pk, nil), + "signing root with nil candidateID must be nil — refuse to produce an unbound digest") + + // SignBLSPop returns an error. + _, err := SignBLSPop(sk, nil) + require.Error(err) + require.Contains(err.Error(), "nil candidate ID") + + // VerifyBLSPop returns an error even before any cryptographic work. + require.Error( + VerifyBLSPop(pk, make([]byte, crypto.BLSAggregateSignatureLength), nil), + "verifier must reject nil candidateID without dispatching the BLS pairing") +} diff --git a/action/protocol/staking/candidate_center.go b/action/protocol/staking/candidate_center.go index 54cdd0714d..b9622ed059 100644 --- a/action/protocol/staking/candidate_center.go +++ b/action/protocol/staking/candidate_center.go @@ -198,31 +198,25 @@ func (m *CandidateCenter) ContainsOwner(owner address.Address) bool { return false } -// ContainsBLSPubKey reports whether any candidate other than the one -// identified by `except` holds the given BLS pubkey. A linear scan is -// used (candidate-center registration is rare and the active set is -// bounded), trading O(N) lookup for not having to maintain a new index -// map across the change/base commit flow. -func (m *CandidateCenter) ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool { +// GetByBLSPubKey returns the candidate that has registered the given +// BLS pubkey, or nil if none does. Linear scan over candidates — +// registration is rare and the candidate set is bounded, trading O(N) +// lookup for not having to maintain another index map across the +// change/base commit flow. Callers that want "any candidate other +// than me" do the identifier comparison on the returned value. +func (m *CandidateCenter) GetByBLSPubKey(blsPubKey []byte) *Candidate { if len(blsPubKey) == 0 { - return false - } - exceptID := "" - if except != nil { - exceptID = except.String() + return nil } for _, d := range m.All() { if len(d.BLSPubKey) == 0 { continue } - if d.GetIdentifier().String() == exceptID { - continue - } if bytes.Equal(d.BLSPubKey, blsPubKey) { - return true + return d } } - return false + return nil } // ContainsOperator returns true if the map contains the candidate by operator diff --git a/action/protocol/staking/candidate_center_test.go b/action/protocol/staking/candidate_center_test.go index 812e21b160..ab40c1aa07 100644 --- a/action/protocol/staking/candidate_center_test.go +++ b/action/protocol/staking/candidate_center_test.go @@ -646,12 +646,13 @@ func TestCandidateUpsert(t *testing.T) { }) } -// TestCandidateCenter_ContainsBLSPubKey covers the uniqueness check used +// TestCandidateCenter_GetByBLSPubKey covers the uniqueness lookup used // by handleCandidateRegister and handleCandidateUpdate to enforce one -// BLS pubkey per delegate — a precondition for IIP-52's quorum-counting -// model (FastAggregateVerify dedups pubkeys but the signer bitmap does -// not). -func TestCandidateCenter_ContainsBLSPubKey(t *testing.T) { +// BLS pubkey per delegate — a precondition for IIP-52's +// quorum-counting model (FastAggregateVerify dedups pubkeys but the +// signer bitmap does not). Callers exclude "self" by comparing the +// returned candidate's identifier to their own. +func TestCandidateCenter_GetByBLSPubKey(t *testing.T) { r := require.New(t) c, err := NewCandidateCenter(nil) r.NoError(err) @@ -672,22 +673,17 @@ func TestCandidateCenter_ContainsBLSPubKey(t *testing.T) { r.NoError(c.Upsert(candA)) r.NoError(c.commit()) - // Empty / nil pubkey never collides. - r.False(c.ContainsBLSPubKey(nil, nil), "nil pubkey is not in use") - r.False(c.ContainsBLSPubKey([]byte{}, nil), "empty pubkey is not in use") - - // Unrelated pubkey does not collide. - r.False(c.ContainsBLSPubKey(pkB, nil), "unrelated pubkey is not in use") - - // pkA is in use by candA. - r.True(c.ContainsBLSPubKey(pkA, nil), - "pkA is registered; ContainsBLSPubKey(except=nil) must report true") - - // candA may re-register / update with the same pkA (no collision against itself). - r.False(c.ContainsBLSPubKey(pkA, candA.GetIdentifier()), - "a candidate must be allowed to keep its own BLS pubkey on update") - - // A different candidate must NOT be allowed to take pkA. - r.True(c.ContainsBLSPubKey(pkA, identityset.Address(2)), - "another candidate trying to take pkA must collide") + // Empty / nil / unregistered pubkey returns nil. + r.Nil(c.GetByBLSPubKey(nil), "nil pubkey returns nil") + r.Nil(c.GetByBLSPubKey([]byte{}), "empty pubkey returns nil") + r.Nil(c.GetByBLSPubKey(pkB), "unregistered pubkey returns nil") + + // pkA returns the holder. Callers compare identifiers to decide + // whether a registration / update should reject — same-identifier + // is allowed (self), different-identifier is the rogue case. + holder := c.GetByBLSPubKey(pkA) + r.NotNil(holder) + r.Equal(candA.Name, holder.Name) + r.Equal(candA.GetIdentifier().String(), holder.GetIdentifier().String(), + "holder's identifier matches the registrant") } diff --git a/action/protocol/staking/candidate_statemanager.go b/action/protocol/staking/candidate_statemanager.go index 7d61e90364..4894817093 100644 --- a/action/protocol/staking/candidate_statemanager.go +++ b/action/protocol/staking/candidate_statemanager.go @@ -49,18 +49,18 @@ type ( ContainsName(string) bool ContainsOwner(address.Address) bool ContainsOperator(address.Address) bool - // ContainsBLSPubKey reports whether any candidate other than the - // one identified by `except` already holds the given BLS pubkey. - // `except` may be nil at registration time (no incumbent), in - // which case the check rejects on any match. Used to enforce one - // BLS pubkey per delegate, a hard requirement for IIP-52's - // FastAggregateVerify quorum-counting model. - ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool ContainsSelfStakingBucket(uint64) bool GetByName(string) *Candidate GetByOwner(address.Address) *Candidate GetByIdentifier(address.Address) *Candidate GetByOperator(address.Address) *Candidate + // GetByBLSPubKey returns the candidate that has registered the + // given BLS pubkey, or nil if no such candidate exists. Used to + // enforce one BLS pubkey per delegate — a hard requirement for + // IIP-52's FastAggregateVerify quorum-counting model. Callers + // that want "is this pubkey held by someone OTHER than X" do + // the identifier comparison themselves on the returned candidate. + GetByBLSPubKey(blsPubKey []byte) *Candidate Upsert(*Candidate) error CreditBucketPool(*big.Int, bool) error DebitBucketPool(*big.Int, bool) error @@ -146,8 +146,8 @@ func (csm *candSM) ContainsOperator(addr address.Address) bool { return csm.candCenter.ContainsOperator(addr) } -func (csm *candSM) ContainsBLSPubKey(blsPubKey []byte, except address.Address) bool { - return csm.candCenter.ContainsBLSPubKey(blsPubKey, except) +func (csm *candSM) GetByBLSPubKey(blsPubKey []byte) *Candidate { + return csm.candCenter.GetByBLSPubKey(blsPubKey) } func (csm *candSM) ContainsSelfStakingBucket(index uint64) bool { diff --git a/action/protocol/staking/handlers.go b/action/protocol/staking/handlers.go index 03b35513c0..d1f20e8e82 100644 --- a/action/protocol/staking/handlers.go +++ b/action/protocol/staking/handlers.go @@ -747,14 +747,15 @@ func (p *Protocol) handleCandidateRegister(ctx context.Context, act *action.Cand // producing an off-by-one mismatch that lets the second delegate's // stake-weight "vote for free". if act.WithBLS() && featureCtx.EnforceBLSPoP { - var except address.Address - if ownerExist { - except = c.GetIdentifier() - } - if csm.ContainsBLSPubKey(act.BLSPubKey(), except) { - return log, nil, &handleError{ - err: errors.New("BLS pubkey already registered by another candidate"), - failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil { + // Re-registration from the same owner (no self-stake yet) + // is allowed to carry forward its existing pubkey; any + // other holder is a collision. + if !ownerExist || holder.GetIdentifier().String() != c.GetIdentifier().String() { + return log, nil, &handleError{ + err: errors.New("BLS pubkey already registered by another candidate"), + failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, + } } } } @@ -923,7 +924,8 @@ func (p *Protocol) handleCandidateUpdate(ctx context.Context, act *action.Candid failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } - if csm.ContainsBLSPubKey(act.BLSPubKey(), c.GetIdentifier()) { + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil && + holder.GetIdentifier().String() != c.GetIdentifier().String() { return log, &handleError{ err: errors.New("BLS pubkey already registered by another candidate"), failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, @@ -986,7 +988,8 @@ func (p *Protocol) handleCandidateUpdateByOperator(ctx context.Context, act *act failureStatus: iotextypes.ReceiptStatus_ErrUnauthorizedOperator, } } - if csm.ContainsBLSPubKey(act.BLSPubKey(), c.GetIdentifier()) { + if holder := csm.GetByBLSPubKey(act.BLSPubKey()); holder != nil && + holder.GetIdentifier().String() != c.GetIdentifier().String() { return log, &handleError{ err: errors.New("BLS pubkey already registered by another candidate"), failureStatus: iotextypes.ReceiptStatus_ErrCandidateConflict, From fbfd7ae92a42c9de5de07a36f8f3af499dd4aece Mon Sep 17 00:00:00 2001 From: envestcc Date: Wed, 17 Jun 2026 09:46:23 +0800 Subject: [PATCH 7/9] refactor(staking): drop blsPubKey from PoP signing root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Comment 3 on PR #4854 (CoderZhi). The previous signing root included blsPubKey: H(blsPopDomain || blsPubKey || candidateID) per the IRTF BLS canonical PoP form. After discussion, switching to: H(blsPopDomain || candidateID) The pairing verifier Verify(PK, msg, sig) already commits PK into the signature equation, so the basic rogue-key registration ("register pk_rogue without owning its discrete log") is blocked by basic PoP correctness without needing pubkey-in-message. Same-message aggregation — the classical attack that pubkey-in-message defends against — requires two distinct honest signers to sign the same signing root; the protocol-enforced uniqueness of candidate identifiers rules that out. Cross-candidate replay is still blocked by the candidateID binding, and cross-domain replay by blsPopDomain. The simpler signing root keeps the on-the-wire calldata independent of pubkey-length/encoding choices and removes an apparent redundancy between the signed message and the verifier's pk argument. SignBLSPop no longer derives pk from sk; the only caller-provided binding is candidateID. The bls_pop bytes themselves are unchanged in length and DST. Updated TestBLSPop_RogueKeyAttackBlocked and TestBLSPop_RejectNilCandidateID to use the new BLSPopSigningRoot(candidateID) signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- action/protocol/staking/bls_pop.go | 69 +++++++++++++------------ action/protocol/staking/bls_pop_test.go | 24 +++++---- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/action/protocol/staking/bls_pop.go b/action/protocol/staking/bls_pop.go index e87b2e7ea6..8e012bec2f 100644 --- a/action/protocol/staking/bls_pop.go +++ b/action/protocol/staking/bls_pop.go @@ -29,46 +29,52 @@ const blsPopDomain = "IOTEX_BLS_POP_v1" // BLSPopSigningRoot returns the bytes that a BLS proof-of-possession // must be computed over for the given candidate. // -// Binding three values into the signed message — the domain tag, the BLS -// public key itself, and the candidate's identity address — closes the -// rogue key attack and two related replays: +// The signed message is the domain tag plus the candidate's stable +// identity. The BLS public key is intentionally NOT in the message: // -// - blsPubKey: forces the signer to know the private key for THIS -// specific BLS pubkey. A rogue pubkey constructed as -// g^x − Σ(other pubkeys) cannot produce a valid PoP because the -// attacker does not know its discrete log. -// - candidateID: prevents two distinct candidates from sharing a -// single BLS keypair (and thus a single PoP) without each owner -// independently re-attesting; also prevents a PoP submitted for -// candidate A from being replayed for candidate B by a man-in-the- -// middle who repackages a CandidateRegister tx. -// - blsPopDomain: keeps PoP signatures disjoint from consensus -// signatures, future PoP schemes, and any other BLS-signed iotex -// message that may exist or be added later. +// - The pairing verifier Verify(PK, msg, sig) already commits PK +// into the signature equation. An attacker who does not know +// sk_PK cannot produce a sig that verifies under PK over any +// message, so the rogue-key registration attack ("register +// pk_rogue without owning its discrete log") is blocked by basic +// PoP correctness without needing pubkey-in-message. +// +// - Cross-candidate replay (PoP for candidate A re-submitted under +// candidate B) is blocked by the candidateID binding: distinct +// candidates have distinct signing roots, so the same signature +// never validates under two candidate identities. +// +// - Cross-domain replay (e.g. PoP reused as a consensus signature) +// is blocked by blsPopDomain. +// +// - The classical same-message aggregation attack on PoP requires +// two distinct honest signers to sign the *same* signing root. The +// candidateID binding rules that out: the protocol enforces unique +// candidate identifiers (see generateCandidateID + the +// ContainsName / ContainsOwner / ContainsOperator checks at +// register time), so no two honest delegates ever produce PoPs +// over the same root. // // candidateID is the candidate's stable identity: -// - At register: the owner address declared in the action. For -// non-collision registrations this becomes c.Identifier verbatim -// (see generateCandidateID — it returns owner directly when free), -// so a PoP signed at register time matches the identifier the -// candidate will carry post-fork. +// +// - At register: act.OwnerAddress() (or actCtx.Caller if omitted), +// which becomes c.Identifier verbatim in the non-collision case +// via generateCandidateID's owner-first fast path. // - At update: c.GetIdentifier(), which returns the immutable // Identifier for post-Xingu candidates and falls back to c.Owner -// for pre-Xingu records. This means a candidate that has been -// transferred to a new owner still uses its original identity for -// PoP, so the binding is stable across ownership transfers. -func BLSPopSigningRoot(blsPubKey []byte, candidateID address.Address) []byte { +// for pre-Xingu records. Stable across CandidateTransferOwnership. +func BLSPopSigningRoot(candidateID address.Address) []byte { // Refuse to produce an "unbound" root. Allowing nil candidateID to - // silently fall through to a domain+pubkey-only digest would - // degrade the scheme to the weakest of its three bindings — - // exactly what an attacker reaching for a cross-candidate replay - // would hope for. Force callers to commit to a candidate identity. + // silently fall through to a domain-only digest collapses the + // scheme to a single global value that every signer would attest + // over — exactly the shape that re-opens the same-message + // aggregation attack. Force callers to commit to a candidate + // identity. if candidateID == nil { return nil } h := sha256.New() h.Write([]byte(blsPopDomain)) - h.Write(blsPubKey) h.Write(candidateID.Bytes()) return h.Sum(nil) } @@ -88,8 +94,7 @@ func SignBLSPop(sk *crypto.BLS12381PrivateKey, candidateID address.Address) ([]b if candidateID == nil { return nil, errors.New("nil candidate ID; PoP must bind to a candidate identity") } - pk := sk.PublicKey().Bytes() - return sk.Sign(BLSPopSigningRoot(pk, candidateID)) + return sk.Sign(BLSPopSigningRoot(candidateID)) } // VerifyBLSPop verifies the proof-of-possession against the provided @@ -108,7 +113,7 @@ func VerifyBLSPop(blsPubKey, blsPop []byte, candidateID address.Address) error { if err != nil { return errors.Wrap(err, "invalid BLS pubkey") } - if !pk.Verify(BLSPopSigningRoot(blsPubKey, candidateID), blsPop) { + if !pk.Verify(BLSPopSigningRoot(candidateID), blsPop) { return errors.New("BLS proof-of-possession verification failed") } return nil diff --git a/action/protocol/staking/bls_pop_test.go b/action/protocol/staking/bls_pop_test.go index 247ef846a2..f05c8fa759 100644 --- a/action/protocol/staking/bls_pop_test.go +++ b/action/protocol/staking/bls_pop_test.go @@ -141,8 +141,10 @@ func TestBLSPop_RejectWrongPubkey(t *testing.T) { // know any sk_i. // // The PoP mitigation: registration requires a BLS signature over -// BLSPopSigningRoot(pk_rogue, owner). Producing this signature -// requires pk_rogue's secret key. The attacker cannot produce one. +// BLSPopSigningRoot(candidateID) that verifies under pk_rogue. +// Producing this signature requires pk_rogue's secret key — the +// pairing-based Verify(pk_rogue, msg, sig) only accepts a signature +// produced with sk_rogue. The attacker has no such key. // // The cleanest test of this property is the abstract one: any pubkey // for which the actor does not know the secret cannot be the subject @@ -179,10 +181,12 @@ func TestBLSPop_RogueKeyAttackBlocked(t *testing.T) { rogueOwner := addrForTest(t, "rogue-owner") // 4. Attacker attempts to register pkRogue. The only PoP they can - // produce is one signed with attackerSK — but - // BLSPopSigningRoot(pkRogue, ...) was supposed to be signed under - // pkRogue's secret key, not attackerSK's. Verification must reject. - attackerForgedPop, err := attackerSK.Sign(BLSPopSigningRoot(pkRogue, rogueOwner)) + // produce is one signed with attackerSK over the canonical + // signing root for the rogue candidate. VerifyBLSPop checks the + // signature under pkRogue (the pubkey the attacker is trying to + // register), and the pairing check requires the signer to be + // pkRogue's secret holder — which the attacker is not. + attackerForgedPop, err := attackerSK.Sign(BLSPopSigningRoot(rogueOwner)) require.NoError(err) require.Error(VerifyBLSPop(pkRogue, attackerForgedPop, rogueOwner), "PoP signed under attackerSK must NOT validate as possession of pkRogue. "+ @@ -207,16 +211,16 @@ func TestBLSPop_RogueKeyAttackBlocked(t *testing.T) { // TestBLSPop_RejectNilCandidateID locks in the contract that the three // PoP entry points refuse to operate without a candidate-identity // binding. Allowing nil candidateID to silently fall through would -// degrade the scheme to a domain+pubkey-only digest — exactly the -// shape an attacker reaching for a cross-candidate replay would hope -// for. Force callers to commit to an identity. +// collapse the scheme to a single domain-only digest that every +// signer would attest over — re-opening the same-message aggregation +// attack the candidateID binding exists to block. func TestBLSPop_RejectNilCandidateID(t *testing.T) { require := require.New(t) sk := blsKeyForTest(t, "any-delegate") pk := sk.PublicKey().Bytes() // BLSPopSigningRoot returns nil. - require.Nil(BLSPopSigningRoot(pk, nil), + require.Nil(BLSPopSigningRoot(nil), "signing root with nil candidateID must be nil — refuse to produce an unbound digest") // SignBLSPop returns an error. From b80f0f316a30009eaa3766c5fe375b06551b3ff2 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 18 Jun 2026 09:50:47 +0800 Subject: [PATCH 8/9] feat(ioctl): BLS PoP UX for stake2 register/update + account bls-sign-pop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the three-option model agreed on PR #4854: how does a delegate actually supply the BLS proof-of-possession the post-fork handler now requires? ## stake2 register / update BREAKING positional change: BLS_PUBKEY moves out of the positional arg list. Pre-fork callers passing it positionally must migrate; the post-fork handler requires PoP anyway so this command was always going to need re-tooling. Three-option matrix lives in blspop_helper.go and applies to both register (mandatory BLS) and update (optional BLS, default = don't touch): Option 1 — auto-derive from signer (default for register): No --bls-* flags. The tool decrypts the signer keystore, derives a BLS key from the ECDSA scalar (same algorithm as `ioctl account blskey` — crypto.GenerateBLS12381PrivateKey(ecdsa_sk.Bytes())), computes the PoP, and prompts the user to confirm. The prompt is explicit about ECDSA↔BLS coupling so delegates aren't surprised when an ECDSA leak also exposes their producer identity. --yes suppresses for CI. Option 2 — --bls-priv-key HEX: Standalone BLS key supplied directly. Tool derives the pubkey and signs the PoP. No prompt. Option 3 — --bls-pubkey HEX + --bls-pop HEX: User supplies both. Tool VerifyBLSPop's locally against the candidateID before submission so a malformed PoP fails fast without burning gas. For register the candidateID is the owner address from positional args; for update it must be passed via --candidate-id. Option 0 (update only): no BLS flags at all → BLS untouched. Going from "leave BLS alone" to "rotate to derived key" requires explicit --bls-from-signer so a name/operator update can never silently rotate the producer key. Mutual-exclusion + completeness rules are centralised in blsPoPFlags.classifyForRegister / classifyForUpdate and locked in by TestBlsPoPFlags_ClassifyForRegister / ClassifyForUpdate. --bls-keystore is a placeholder; the iotex BLS keystore format isn't specified yet. Tool returns an explicit error pointing at --bls-priv-key. ## account bls-sign-pop New offline PoP signer for air-gapped workflows. Accepts either --bls-priv-key HEX or --signer ADDRESS (ECDSA keystore → derive), plus --candidate-id ADDRESS. Writes the 96-byte PoP hex to stdout (informational fields go to stderr so shell redirection captures only the PoP). The delegate signs on the cold machine, transfers the hex over USB / QR, and runs `ioctl stake2 register --bls-pubkey ... --bls-pop ...` on a hot machine — the BLS private key never leaves cold storage. Lives in the account package next to the existing `account blskey` so BLS key operations are co-located rather than scattered. Co-Authored-By: Claude Opus 4.7 (1M context) --- ioctl/cmd/account/account.go | 1 + ioctl/cmd/account/accountblssignpop.go | 146 +++++++++++ ioctl/cmd/action/blspop_helper.go | 332 +++++++++++++++++++++++++ ioctl/cmd/action/blspop_helper_test.go | 129 ++++++++++ ioctl/cmd/action/stake2register.go | 72 ++++-- ioctl/cmd/action/stake2update.go | 68 +++-- 6 files changed, 701 insertions(+), 47 deletions(-) create mode 100644 ioctl/cmd/account/accountblssignpop.go create mode 100644 ioctl/cmd/action/blspop_helper.go create mode 100644 ioctl/cmd/action/blspop_helper_test.go diff --git a/ioctl/cmd/account/account.go b/ioctl/cmd/account/account.go index 72ffc226a8..368ba648e0 100644 --- a/ioctl/cmd/account/account.go +++ b/ioctl/cmd/account/account.go @@ -71,6 +71,7 @@ var AccountCmd = &cobra.Command{ func init() { AccountCmd.AddCommand(accountBalanceCmd) AccountCmd.AddCommand(_accountBlsCmd) + AccountCmd.AddCommand(_accountBlsSignPoPCmd) AccountCmd.AddCommand(_accountCreateCmd) AccountCmd.AddCommand(_accountCreateAddCmd) AccountCmd.AddCommand(_accountDeleteCmd) diff --git a/ioctl/cmd/account/accountblssignpop.go b/ioctl/cmd/account/accountblssignpop.go new file mode 100644 index 0000000000..cf163d73e8 --- /dev/null +++ b/ioctl/cmd/account/accountblssignpop.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package account + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/ioctl/config" + "github.com/iotexproject/iotex-core/v2/ioctl/output" + "github.com/iotexproject/iotex-core/v2/ioctl/util" +) + +// Multi-language support +var ( + _accountBlsSignPoPCmdShorts = map[config.Language]string{ + config.English: "Sign a BLS proof-of-possession offline (air-gap friendly)", + config.Chinese: "离线签名 BLS 持有证明(适合冷机器场景)", + } + _accountBlsSignPoPCmdLongs = map[config.Language]string{ + config.English: `Generate a BLS proof-of-possession for the given candidate identity, +without sending any transaction. Intended for air-gapped workflows +where the BLS private key never touches a network-connected machine — +sign the PoP here, transfer the resulting hex back to a hot machine, +and pass it to "ioctl stake2 register --bls-pubkey ... --bls-pop ...".`, + config.Chinese: `为指定的 candidate 身份生成 BLS 持有证明,但不发送任何交易。 +适合 air-gap 场景——BLS 私钥永不接触联网机器,在冷机器上签好 PoP, +把 hex 转回热机器,喂给 ioctl stake2 register --bls-pubkey ... --bls-pop ...`, + } + _accountBlsSignPoPCmdUse = map[config.Language]string{ + config.English: "bls-sign-pop --candidate-id ADDRESS (--bls-priv-key HEX | --signer ADDRESS)", + config.Chinese: "bls-sign-pop --candidate-id 地址 (--bls-priv-key HEX | --signer 地址)", + } + + _accountBlsSignPoPCandidateID string + _accountBlsSignPoPBLSPrivKey string + _accountBlsSignPoPSigner string +) + +// _accountBlsSignPoPCmd produces a BLS PoP hex for a given candidateID. +// +// Two key sources: +// +// --bls-priv-key HEX +// Use a standalone BLS private key. Bytes are read directly; nothing +// is decrypted, so this flow is safe to use on an offline machine. +// +// --signer ADDRESS (paired with -P PASSWORD) +// Use the BLS key derived from an existing iotex account's ECDSA +// private key — same scheme as `ioctl account blskey`. Requires +// the keystore + password to be present. +// +// Exactly one of the two must be supplied. Output is the 96-byte PoP +// hex written to stdout (so it composes with shell redirection). +var _accountBlsSignPoPCmd = &cobra.Command{ + Use: config.TranslateInLang(_accountBlsSignPoPCmdUse, config.UILanguage), + Short: config.TranslateInLang(_accountBlsSignPoPCmdShorts, config.UILanguage), + Long: config.TranslateInLang(_accountBlsSignPoPCmdLongs, config.UILanguage), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + return output.PrintError(runBlsSignPoP()) + }, +} + +func init() { + RegisterPasswordFlag(_accountBlsSignPoPCmd) + f := _accountBlsSignPoPCmd.Flags() + f.StringVar(&_accountBlsSignPoPCandidateID, "candidate-id", "", + "Candidate identifier address (the value c.GetIdentifier() returns on chain). At register time this is the owner address declared in the action.") + f.StringVar(&_accountBlsSignPoPBLSPrivKey, "bls-priv-key", "", + "BLS private key (32 B hex). Mutually exclusive with --signer.") + f.StringVar(&_accountBlsSignPoPSigner, "signer", "", + "iotex address whose ECDSA keystore is used to derive a BLS key (same algorithm as `ioctl account blskey`). Mutually exclusive with --bls-priv-key.") +} + +func runBlsSignPoP() error { + if _accountBlsSignPoPCandidateID == "" { + return output.NewError(output.FlagError, "--candidate-id is required", nil) + } + candAddrStr, err := util.Address(_accountBlsSignPoPCandidateID) + if err != nil { + return output.NewError(output.AddressError, "invalid --candidate-id", err) + } + candAddr, err := address.FromString(candAddrStr) + if err != nil { + return output.NewError(output.AddressError, "invalid --candidate-id", err) + } + + if (_accountBlsSignPoPBLSPrivKey == "") == (_accountBlsSignPoPSigner == "") { + return output.NewError(output.FlagError, + "exactly one of --bls-priv-key or --signer must be supplied", nil) + } + + var blsSk *crypto.BLS12381PrivateKey + switch { + case _accountBlsSignPoPBLSPrivKey != "": + b, err := hex.DecodeString(strings.TrimPrefix(_accountBlsSignPoPBLSPrivKey, "0x")) + if err != nil { + return output.NewError(output.ConvertError, "invalid --bls-priv-key hex", err) + } + blsSk, err = crypto.BLS12381PrivateKeyFromBytes(b) + if err != nil { + return output.NewError(output.ValidationError, "invalid BLS private key bytes", err) + } + case _accountBlsSignPoPSigner != "": + signerStr, err := util.Address(_accountBlsSignPoPSigner) + if err != nil { + return output.NewError(output.AddressError, "invalid --signer", err) + } + ecdsaSk, err := PrivateKeyFromSigner(signerStr, PasswordByFlag()) + if err != nil { + return output.NewError(output.KeystoreError, "failed to decrypt signer keystore", err) + } + blsSk, err = crypto.GenerateBLS12381PrivateKey(ecdsaSk.Bytes()) + ecdsaSk.Zero() + if err != nil { + return errors.Wrap(err, "failed to derive BLS key") + } + } + defer blsSk.Zero() + + pop, err := staking.SignBLSPop(blsSk, candAddr) + if err != nil { + return errors.Wrap(err, "failed to sign BLS PoP") + } + + // Print pubkey + PoP. Pubkey on stderr (informational) so stdout + // stays exactly the PoP hex for clean shell redirection. + fmt.Fprintf(os.Stderr, "BLS pubkey: 0x%s\n", hex.EncodeToString(blsSk.PublicKey().Bytes())) + fmt.Fprintf(os.Stderr, "Candidate ID: %s\n", candAddr.String()) + fmt.Fprintf(os.Stderr, "PoP bytes: %d\n", len(pop)) + fmt.Println(hex.EncodeToString(pop)) + return nil +} diff --git a/ioctl/cmd/action/blspop_helper.go b/ioctl/cmd/action/blspop_helper.go new file mode 100644 index 0000000000..3433d4eb4d --- /dev/null +++ b/ioctl/cmd/action/blspop_helper.go @@ -0,0 +1,332 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package action + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/pkg/errors" + + "github.com/iotexproject/iotex-core/v2/action/protocol/staking" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" + "github.com/iotexproject/iotex-core/v2/ioctl/output" + "github.com/iotexproject/iotex-core/v2/ioctl/util" +) + +// blsPoPFlags holds the user's BLS proof-of-possession choices for +// stake2 register / update. The three-option matrix: +// +// Option 1 (default, register only): all flags empty. +// Tool derives a BLS keypair deterministically from the signer's +// ECDSA private key (same scheme as `ioctl account blskey`), +// computes the PoP, and prompts the user to confirm before +// proceeding. --yes suppresses the prompt for scripted use. +// +// Option 2: --bls-priv-key (hex) supplied. +// Tool uses the provided BLS key, derives its pubkey, computes +// the PoP. No prompt. +// +// Option 3: --bls-pubkey and --bls-pop both supplied. +// Tool sends as-is. If --candidate-id is also supplied the PoP is +// verified locally before submission so a malformed PoP fails +// fast without burning gas. +// +// For update, the matrix is augmented with one extra Option 0: +// +// Option 0 (update only): all BLS flags empty. +// No BLS field is sent — the update touches only the non-BLS +// fields (name / operator / reward). +// +// Update Option 1 is opt-in via --bls-from-signer because the +// implicit default for "update with no BLS flag" is "don't touch BLS", +// not "rotate to the derived key." +type blsPoPFlags struct { + pubKeyHex string // --bls-pubkey + popHex string // --bls-pop + privKeyHex string // --bls-priv-key + keystorePath string // --bls-keystore (placeholder, see error message) + fromSigner bool // --bls-from-signer (update path Option 1 opt-in) + autoConfirm bool // --yes / -y + candidateIDStr string // --candidate-id (for update; optional local verify in register Option 3) +} + +// derivedBLSFromSigner decrypts the signer's ECDSA keystore and derives +// a BLS private key from it via the same algorithm as +// `ioctl account blskey` (ECDSA bytes used as IKM for +// crypto.GenerateBLS12381PrivateKey). Returns the typed BLS private +// key so the caller can both publish its pubkey and sign the PoP. +func derivedBLSFromSigner(signer, password string) (*crypto.BLS12381PrivateKey, error) { + ecdsaSk, err := account.PrivateKeyFromSigner(signer, password) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt signer keystore") + } + defer ecdsaSk.Zero() + bls, err := crypto.GenerateBLS12381PrivateKey(ecdsaSk.Bytes()) + if err != nil { + return nil, errors.Wrap(err, "failed to derive BLS key from ECDSA") + } + return bls, nil +} + +// confirmDerivedBLS prints the auto-derived pubkey and coupling warning +// and waits for the user to type y/yes. Bypassed by --yes. The text is +// intentionally explicit about the coupling cost — the user is opting +// into "ECDSA leak compromises BLS identity too" and they should know. +func confirmDerivedBLS(signerAddr string, blsPubKey []byte, autoConfirm bool) error { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "No BLS credential flags provided. The tool will derive a BLS keypair from the") + fmt.Fprintln(os.Stderr, "signer's ECDSA private key (same mechanism as `ioctl account blskey`).") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintf(os.Stderr, " Signer: %s\n", signerAddr) + fmt.Fprintf(os.Stderr, " BLS pubkey: 0x%s\n", hex.EncodeToString(blsPubKey)) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Coupling: a leak of your signer ECDSA private key would also expose this BLS key,") + fmt.Fprintln(os.Stderr, "since anyone holding the ECDSA key can re-derive the BLS key. For separate") + fmt.Fprintln(os.Stderr, "security domains use --bls-priv-key with a standalone BLS keypair instead.") + fmt.Fprintln(os.Stderr, "") + if autoConfirm { + fmt.Fprintln(os.Stderr, "--yes supplied; proceeding with derived BLS key.") + return nil + } + fmt.Fprint(os.Stderr, "Proceed with derived BLS key? [y/N]: ") + resp, err := util.ReadSecretFromStdin() + if err != nil { + return errors.Wrap(err, "failed to read confirmation") + } + switch strings.ToLower(strings.TrimSpace(resp)) { + case "y", "yes": + return nil + default: + return errors.New("aborted: user declined to use derived BLS key") + } +} + +// resolveBLSForRegister returns (blsPubKey, blsPop) for the register +// command per the three-option matrix. ownerAddr is the candidateID +// at registration time (which becomes c.Identifier verbatim in the +// non-collision case via generateCandidateID's owner-first fast path). +func resolveBLSForRegister(flags *blsPoPFlags, signer, password string, ownerAddr address.Address) ([]byte, []byte, error) { + if flags.keystorePath != "" { + return nil, nil, output.NewError(output.FlagError, + "--bls-keystore is not yet supported; use --bls-priv-key for now", nil) + } + mode, err := flags.classifyForRegister() + if err != nil { + return nil, nil, err + } + switch mode { + case blsModeAutoDerive: + sk, err := derivedBLSFromSigner(signer, password) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + if err := confirmDerivedBLS(signer, pk, flags.autoConfirm); err != nil { + return nil, nil, err + } + pop, err := staking.SignBLSPop(sk, ownerAddr) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitKey: + sk, err := loadBLSPrivKey(flags.privKeyHex) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + pop, err := staking.SignBLSPop(sk, ownerAddr) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitPoP: + pk, pop, err := flags.decodeExplicitPubKeyAndPoP() + if err != nil { + return nil, nil, err + } + // Local fail-fast verification. ownerAddr is the canonical + // candidateID for register so we can verify immediately + // without an extra --candidate-id flag. + if err := staking.VerifyBLSPop(pk, pop, ownerAddr); err != nil { + return nil, nil, errors.Wrap(err, "supplied PoP fails local verification against ownerAddress") + } + return pk, pop, nil + } + return nil, nil, errors.New("unreachable: unhandled BLS mode") +} + +// resolveBLSForUpdate returns (blsPubKey, blsPop) for the update +// command, or (nil, nil) if the user supplied no BLS flags at all +// (Option 0 — leave Candidate.BLSPubKey unchanged). candidateID is +// required for any non-zero option; the caller is expected to supply +// it via --candidate-id (a future improvement would resolve via RPC +// from the signer). +func resolveBLSForUpdate(flags *blsPoPFlags, signer, password string) ([]byte, []byte, error) { + if flags.keystorePath != "" { + return nil, nil, output.NewError(output.FlagError, + "--bls-keystore is not yet supported; use --bls-priv-key for now", nil) + } + mode, err := flags.classifyForUpdate() + if err != nil { + return nil, nil, err + } + if mode == blsModeNone { + return nil, nil, nil + } + if flags.candidateIDStr == "" { + return nil, nil, output.NewError(output.FlagError, + "BLS update requires --candidate-id (the candidate's current identifier); "+ + "future versions will resolve this via RPC from the signer", nil) + } + candidateID, err := address.FromString(flags.candidateIDStr) + if err != nil { + return nil, nil, output.NewError(output.AddressError, "invalid --candidate-id", err) + } + switch mode { + case blsModeAutoDerive: + sk, err := derivedBLSFromSigner(signer, password) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + if err := confirmDerivedBLS(signer, pk, flags.autoConfirm); err != nil { + return nil, nil, err + } + pop, err := staking.SignBLSPop(sk, candidateID) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitKey: + sk, err := loadBLSPrivKey(flags.privKeyHex) + if err != nil { + return nil, nil, err + } + defer sk.Zero() + pk := sk.PublicKey().Bytes() + pop, err := staking.SignBLSPop(sk, candidateID) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to sign BLS PoP") + } + return pk, pop, nil + + case blsModeExplicitPoP: + pk, pop, err := flags.decodeExplicitPubKeyAndPoP() + if err != nil { + return nil, nil, err + } + if err := staking.VerifyBLSPop(pk, pop, candidateID); err != nil { + return nil, nil, errors.Wrap(err, "supplied PoP fails local verification against --candidate-id") + } + return pk, pop, nil + } + return nil, nil, errors.New("unreachable: unhandled BLS mode") +} + +// loadBLSPrivKey parses a hex BLS private key. Reusable across +// register / update / bls-sign-pop. +func loadBLSPrivKey(hexStr string) (*crypto.BLS12381PrivateKey, error) { + if hexStr == "" { + return nil, errors.New("empty --bls-priv-key") + } + hexStr = strings.TrimPrefix(hexStr, "0x") + b, err := hex.DecodeString(hexStr) + if err != nil { + return nil, errors.Wrap(err, "invalid --bls-priv-key hex") + } + sk, err := crypto.BLS12381PrivateKeyFromBytes(b) + if err != nil { + return nil, errors.Wrap(err, "invalid BLS private key bytes") + } + return sk, nil +} + +// blsMode is the resolved option selected by the user's flags. +type blsMode int + +const ( + blsModeNone blsMode = iota // update-only Option 0: don't touch BLS + blsModeAutoDerive // Option 1: derive BLS from signer + blsModeExplicitKey // Option 2: --bls-priv-key (or --bls-keystore in the future) + blsModeExplicitPoP // Option 3: --bls-pubkey + --bls-pop +) + +func (f *blsPoPFlags) classifyForRegister() (blsMode, error) { + switch { + case f.pubKeyHex != "" && f.popHex != "": + if f.privKeyHex != "" { + return 0, errMutex("--bls-pubkey/--bls-pop", "--bls-priv-key") + } + return blsModeExplicitPoP, nil + case f.pubKeyHex != "" || f.popHex != "": + return 0, output.NewError(output.FlagError, + "--bls-pubkey and --bls-pop must be specified together, or use --bls-priv-key, "+ + "or supply neither for auto-derive from signer", nil) + case f.privKeyHex != "": + return blsModeExplicitKey, nil + default: + return blsModeAutoDerive, nil + } +} + +func (f *blsPoPFlags) classifyForUpdate() (blsMode, error) { + switch { + case f.pubKeyHex != "" && f.popHex != "": + if f.privKeyHex != "" || f.fromSigner { + return 0, errMutex("--bls-pubkey/--bls-pop", "--bls-priv-key/--bls-from-signer") + } + return blsModeExplicitPoP, nil + case f.pubKeyHex != "" || f.popHex != "": + return 0, output.NewError(output.FlagError, + "--bls-pubkey and --bls-pop must be specified together; use --bls-priv-key or "+ + "--bls-from-signer to have the tool compute the PoP", nil) + case f.privKeyHex != "": + if f.fromSigner { + return 0, errMutex("--bls-priv-key", "--bls-from-signer") + } + return blsModeExplicitKey, nil + case f.fromSigner: + return blsModeAutoDerive, nil + default: + return blsModeNone, nil + } +} + +func (f *blsPoPFlags) decodeExplicitPubKeyAndPoP() ([]byte, []byte, error) { + pk, err := hex.DecodeString(strings.TrimPrefix(f.pubKeyHex, "0x")) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid --bls-pubkey hex") + } + if _, err := crypto.BLS12381PublicKeyFromBytes(pk); err != nil { + return nil, nil, errors.Wrap(err, "invalid BLS pubkey bytes") + } + pop, err := hex.DecodeString(strings.TrimPrefix(f.popHex, "0x")) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid --bls-pop hex") + } + if len(pop) != crypto.BLSAggregateSignatureLength { + return nil, nil, errors.Errorf("invalid --bls-pop length: got %d, want %d", + len(pop), crypto.BLSAggregateSignatureLength) + } + return pk, pop, nil +} + +func errMutex(a, b string) error { + return output.NewError(output.FlagError, + fmt.Sprintf("%s and %s are mutually exclusive", a, b), nil) +} diff --git a/ioctl/cmd/action/blspop_helper_test.go b/ioctl/cmd/action/blspop_helper_test.go new file mode 100644 index 0000000000..f57a735d4b --- /dev/null +++ b/ioctl/cmd/action/blspop_helper_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package action + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBlsPoPFlags_ClassifyForRegister exercises the three-option matrix +// at the register command, plus the rejection cases for partial input. +// The classifier is the only stateful piece between flag parsing and +// the cryptographic resolve path, so covering it explicitly catches +// regressions without having to spin up cobra / keystore plumbing. +func TestBlsPoPFlags_ClassifyForRegister(t *testing.T) { + cases := []struct { + name string + f blsPoPFlags + want blsMode + errMsg string + }{ + {"all empty → auto-derive", blsPoPFlags{}, blsModeAutoDerive, ""}, + {"only --bls-priv-key → explicit key", + blsPoPFlags{privKeyHex: "deadbeef"}, blsModeExplicitKey, ""}, + {"--bls-pubkey + --bls-pop → explicit PoP", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01"}, blsModeExplicitPoP, ""}, + {"only --bls-pubkey → error (incomplete)", + blsPoPFlags{pubKeyHex: "abcd"}, 0, "must be specified together"}, + {"only --bls-pop → error (incomplete)", + blsPoPFlags{popHex: "ef01"}, 0, "must be specified together"}, + {"--bls-priv-key + --bls-pubkey + --bls-pop → mutually exclusive", + blsPoPFlags{privKeyHex: "deadbeef", pubKeyHex: "abcd", popHex: "ef01"}, + 0, "mutually exclusive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + mode, err := tc.f.classifyForRegister() + if tc.errMsg != "" { + r.Error(err) + r.Contains(err.Error(), tc.errMsg) + return + } + r.NoError(err) + r.Equal(tc.want, mode) + }) + } +} + +// TestBlsPoPFlags_ClassifyForUpdate covers the update-only Option 0 +// (no BLS flags → don't touch BLS) plus the rejection branches that +// keep "rotate BLS" explicit so a name/operator-only update never +// silently rotates the producer key. +func TestBlsPoPFlags_ClassifyForUpdate(t *testing.T) { + cases := []struct { + name string + f blsPoPFlags + want blsMode + errMsg string + }{ + {"all empty → Option 0 (no BLS)", blsPoPFlags{}, blsModeNone, ""}, + {"--bls-from-signer → auto-derive", + blsPoPFlags{fromSigner: true}, blsModeAutoDerive, ""}, + {"--bls-priv-key → explicit key", + blsPoPFlags{privKeyHex: "deadbeef"}, blsModeExplicitKey, ""}, + {"--bls-pubkey + --bls-pop → explicit PoP", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01"}, blsModeExplicitPoP, ""}, + {"only --bls-pubkey → error", + blsPoPFlags{pubKeyHex: "abcd"}, 0, "must be specified together"}, + {"only --bls-pop → error", + blsPoPFlags{popHex: "ef01"}, 0, "must be specified together"}, + {"--bls-priv-key + --bls-from-signer → mutually exclusive", + blsPoPFlags{privKeyHex: "deadbeef", fromSigner: true}, + 0, "mutually exclusive"}, + {"--bls-pubkey + --bls-pop + --bls-from-signer → mutually exclusive", + blsPoPFlags{pubKeyHex: "abcd", popHex: "ef01", fromSigner: true}, + 0, "mutually exclusive"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + mode, err := tc.f.classifyForUpdate() + if tc.errMsg != "" { + r.Error(err) + r.Contains(err.Error(), tc.errMsg) + return + } + r.NoError(err) + r.Equal(tc.want, mode) + }) + } +} + +// TestLoadBLSPrivKey covers the hex parse + length-validate path, +// including the 0x prefix accepting. +func TestLoadBLSPrivKey(t *testing.T) { + r := require.New(t) + + // 32-byte hex (valid scalar — derived deterministically from sha256 + // in tests would be better, but for codec coverage any non-zero + // 32B value the BLS key parser accepts will do). + good := "11111111111111111111111111111111" + "11111111111111111111111111111111" + sk, err := loadBLSPrivKey(good) + r.NoError(err) + r.NotNil(sk) + sk.Zero() + + // 0x prefix accepted. + sk, err = loadBLSPrivKey("0x" + good) + r.NoError(err) + r.NotNil(sk) + sk.Zero() + + // Empty rejected. + _, err = loadBLSPrivKey("") + r.Error(err) + + // Non-hex rejected. + _, err = loadBLSPrivKey("zzzzzz") + r.Error(err) + + // Wrong length rejected. + _, err = loadBLSPrivKey("dead") + r.Error(err) +} diff --git a/ioctl/cmd/action/stake2register.go b/ioctl/cmd/action/stake2register.go index 6b992f87f5..2c95d2043b 100644 --- a/ioctl/cmd/action/stake2register.go +++ b/ioctl/cmd/action/stake2register.go @@ -8,11 +8,11 @@ package action import ( "encoding/hex" + "github.com/iotexproject/iotex-address/address" "github.com/spf13/cobra" - "github.com/iotexproject/go-pkgs/crypto" - "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" "github.com/iotexproject/iotex-core/v2/ioctl/config" "github.com/iotexproject/iotex-core/v2/ioctl/output" "github.com/iotexproject/iotex-core/v2/ioctl/util" @@ -21,21 +21,31 @@ import ( // Multi-language support var ( _registerCmdUses = map[config.Language]string{ - config.English: "register NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) (ALIAS|OWNER_ADDRESS) BLS_PUBKEY AMOUNT_IOTX STAKE_DURATION [DATA] [--auto-stake] [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", - config.Chinese: "register 名字 (别名|操作者地址)(别名|奖励地址)(别名|所有者地址)BLS公钥 IOTX数量 质押持续时间 [数据] [--auto-stake] [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", + config.English: "register NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) (ALIAS|OWNER_ADDRESS) AMOUNT_IOTX STAKE_DURATION [DATA] [--auto-stake] [BLS-FLAGS] [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", + config.Chinese: "register 名字 (别名|操作者地址)(别名|奖励地址)(别名|所有者地址)IOTX数量 质押持续时间 [数据] [--auto-stake] [BLS标志] [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", } _registerCmdShorts = map[config.Language]string{ - config.English: "Register a candidate", - config.Chinese: "在IoTeX区块链上注册候选人", + config.English: "Register a candidate (with BLS proof-of-possession)", + config.Chinese: "在IoTeX区块链上注册候选人(含 BLS 持有证明)", } + + // BLS PoP flags used by stake2 register. + _registerBLSFlags blsPoPFlags ) -// _stake2RegisterCmd represents the stake2 register a candidate command +// _stake2RegisterCmd represents the stake2 register a candidate command. +// +// Positional args (BREAKING CHANGE — was 7/8 with BLS_PUBKEY at args[4]): +// +// NAME OPERATOR REWARD OWNER AMOUNT DURATION [DATA] +// +// BLS_PUBKEY is now supplied through a flag along with the new PoP +// material. See blspop_helper.go for the three-option matrix. var _stake2RegisterCmd = &cobra.Command{ Use: config.TranslateInLang(_registerCmdUses, config.UILanguage), Short: config.TranslateInLang(_registerCmdShorts, config.UILanguage), - Args: cobra.RangeArgs(7, 8), + Args: cobra.RangeArgs(6, 7), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true err := register(args) @@ -46,6 +56,18 @@ var _stake2RegisterCmd = &cobra.Command{ func init() { RegisterWriteCommand(_stake2RegisterCmd) _stake2RegisterCmd.Flags().BoolVar(&_stake2AutoStake, "auto-stake", false, config.TranslateInLang(_stake2FlagAutoStakeUsages, config.UILanguage)) + + f := _stake2RegisterCmd.Flags() + f.StringVar(&_registerBLSFlags.pubKeyHex, "bls-pubkey", "", + "BLS public key (hex). Use with --bls-pop for Option 3 (explicit PoP). Pre-decoded validation runs locally.") + f.StringVar(&_registerBLSFlags.popHex, "bls-pop", "", + "BLS proof-of-possession (96 B hex). Pairs with --bls-pubkey for Option 3.") + f.StringVar(&_registerBLSFlags.privKeyHex, "bls-priv-key", "", + "BLS private key (32 B hex) — Option 2. Tool derives the pubkey + signs the PoP. WARNING: appears in shell history.") + f.StringVar(&_registerBLSFlags.keystorePath, "bls-keystore", "", + "BLS keystore path — placeholder; not yet implemented.") + f.BoolVar(&_registerBLSFlags.autoConfirm, "yes", false, + "Skip the auto-derive confirmation prompt (Option 1). Use for CI / scripted flows.") } func register(args []string) error { @@ -66,31 +88,25 @@ func register(args []string) error { if err != nil { return output.NewError(output.AddressError, "failed to get owner address", err) } - - // Validate and parse BLS public key - blsPubKeyStr := args[4] - blsPubKeyBytes, err := hex.DecodeString(blsPubKeyStr) + ownerAddr, err := address.FromString(ownerAddrStr) if err != nil { - return output.NewError(output.ConvertError, "failed to decode BLS public key", err) - } - if _, err = crypto.BLS12381PublicKeyFromBytes(blsPubKeyBytes); err != nil { - return output.NewError(output.ValidationError, "invalid BLS public key", err) + return output.NewError(output.AddressError, "invalid owner address", err) } - amountInRau, err := util.StringToRau(args[5], util.IotxDecimalNum) + amountInRau, err := util.StringToRau(args[4], util.IotxDecimalNum) if err != nil { return output.NewError(output.ConvertError, "invalid amount", err) } - stakeDuration, err := parseStakeDuration(args[6]) + stakeDuration, err := parseStakeDuration(args[5]) if err != nil { return output.NewError(0, "", err) } duration := uint32(stakeDuration.Uint64()) var payload []byte - if len(args) == 8 { - payload, err = hex.DecodeString(args[7]) + if len(args) == 7 { + payload, err = hex.DecodeString(args[6]) if err != nil { return output.NewError(output.ConvertError, "failed to decode data", err) } @@ -101,6 +117,15 @@ func register(args []string) error { return output.NewError(output.AddressError, "failed to get signed address", err) } + // Resolve the BLS pubkey + PoP via the three-option matrix. + // candidateID at register time is the owner address; in the + // non-collision case this becomes c.Identifier verbatim via + // generateCandidateID's owner-first fast path. + blsPubKey, blsPop, err := resolveBLSForRegister(&_registerBLSFlags, sender, account.PasswordByFlag(), ownerAddr) + if err != nil { + return err + } + gasLimit := _gasLimitFlag.Value().(uint64) if gasLimit == 0 { gasLimit = action.CandidateRegisterBaseIntrinsicGas + @@ -115,12 +140,9 @@ func register(args []string) error { if err != nil { return output.NewError(0, "failed to get nonce ", err) } - // TODO: derive blsPop from a user-supplied BLS private key and pass - // it here once the ioctl flow supports it. Pre-fork the PoP is - // optional; post-fork (EnforceBLSPoP active) the handler will reject - // the registration if blsPop is empty. - cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, amountInRau.String(), duration, _stake2AutoStake, blsPubKeyBytes, nil, payload) + cr, err := action.NewCandidateRegisterWithBLS(name, operatorAddrStr, rewardAddrStr, ownerAddrStr, + amountInRau.String(), duration, _stake2AutoStake, blsPubKey, blsPop, payload) if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateRegister instance", err) } diff --git a/ioctl/cmd/action/stake2update.go b/ioctl/cmd/action/stake2update.go index ba5775a830..fd55894cf8 100644 --- a/ioctl/cmd/action/stake2update.go +++ b/ioctl/cmd/action/stake2update.go @@ -6,13 +6,10 @@ package action import ( - "encoding/hex" - "github.com/spf13/cobra" - "github.com/iotexproject/go-pkgs/crypto" - "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/ioctl/cmd/account" "github.com/iotexproject/iotex-core/v2/ioctl/config" "github.com/iotexproject/iotex-core/v2/ioctl/output" "github.com/iotexproject/iotex-core/v2/ioctl/util" @@ -21,21 +18,32 @@ import ( // Multi-language support var ( _stake2UpdateCmdUses = map[config.Language]string{ - config.English: "update NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) BLS_PUBKEY" + + config.English: "update NAME (ALIAS|OPERATOR_ADDRESS) (ALIAS|REWARD_ADDRESS) [BLS-FLAGS]" + " [-s SIGNER] [-n NONCE] [-l GAS_LIMIT] [-p GAS_PRICE] [-P PASSWORD] [-y]", - config.Chinese: "update 名字 (别名|操作者地址) (别名|奖励地址) BLS公钥" + + config.Chinese: "update 名字 (别名|操作者地址) (别名|奖励地址) [BLS标志]" + " [-s 签署人] [-n NONCE] [-l GAS限制] [-p GAS价格] [-P 密码] [-y]", } _stake2UpdateCmdShorts = map[config.Language]string{ - config.English: "Update candidate on IoTeX blockchain", - config.Chinese: "在IoTeX区块链上更新候选人", + config.English: "Update candidate (BLS rotation requires --candidate-id; without BLS flags BLS is untouched)", + config.Chinese: "更新候选人(BLS 旋转需 --candidate-id;不指定 BLS 标志时不动 BLS)", } + + _updateBLSFlags blsPoPFlags ) +// _stake2UpdateCmd updates a candidate. Positional args (BREAKING CHANGE — +// was 4 with BLS_PUBKEY at args[3]): +// +// NAME OPERATOR REWARD +// +// BLS rotation is now opt-in via flags. Run without any BLS flag to +// touch only name / operator / reward. See blspop_helper.go for the +// three-option matrix governing BLS rotation, plus --bls-from-signer +// for the explicit "rotate to the signer-derived BLS key" opt-in. var _stake2UpdateCmd = &cobra.Command{ Use: config.TranslateInLang(_stake2UpdateCmdUses, config.UILanguage), Short: config.TranslateInLang(_stake2UpdateCmdShorts, config.UILanguage), - Args: cobra.ExactArgs(4), + Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true err := stake2Update(args) @@ -45,6 +53,22 @@ var _stake2UpdateCmd = &cobra.Command{ func init() { RegisterWriteCommand(_stake2UpdateCmd) + + f := _stake2UpdateCmd.Flags() + f.StringVar(&_updateBLSFlags.pubKeyHex, "bls-pubkey", "", + "BLS public key (hex). Use with --bls-pop for Option 3 (explicit PoP).") + f.StringVar(&_updateBLSFlags.popHex, "bls-pop", "", + "BLS proof-of-possession (96 B hex). Pairs with --bls-pubkey for Option 3.") + f.StringVar(&_updateBLSFlags.privKeyHex, "bls-priv-key", "", + "BLS private key (32 B hex) — Option 2. Tool derives the pubkey + signs the PoP.") + f.StringVar(&_updateBLSFlags.keystorePath, "bls-keystore", "", + "BLS keystore path — placeholder; not yet implemented.") + f.BoolVar(&_updateBLSFlags.fromSigner, "bls-from-signer", false, + "Opt-in: rotate to the BLS key derived from the signer's ECDSA private key (Option 1 for update). Requires --candidate-id.") + f.BoolVar(&_updateBLSFlags.autoConfirm, "yes", false, + "Skip the --bls-from-signer confirmation prompt. Use for CI / scripted flows.") + f.StringVar(&_updateBLSFlags.candidateIDStr, "candidate-id", "", + "Candidate identifier address (c.GetIdentifier()) — required when rotating BLS. Future versions will resolve this via RPC from the signer.") } func stake2Update(args []string) error { @@ -62,19 +86,17 @@ func stake2Update(args []string) error { return output.NewError(output.AddressError, "failed to get reward address", err) } - // Validate and parse BLS public key - blsPubKeyStr := args[3] - blsPubKeyBytes, err := hex.DecodeString(blsPubKeyStr) + sender, err := Signer() if err != nil { - return output.NewError(output.ConvertError, "failed to decode BLS public key", err) - } - if _, err = crypto.BLS12381PublicKeyFromBytes(blsPubKeyBytes); err != nil { - return output.NewError(output.ValidationError, "invalid BLS public key", err) + return output.NewError(output.AddressError, "failed to get signed address", err) } - sender, err := Signer() + // Resolve BLS pubkey + PoP. Returns (nil, nil, nil) for Option 0 + // (no BLS flags) — the resulting action leaves c.BLSPubKey + // unchanged on the handler side. + blsPubKey, blsPop, err := resolveBLSForUpdate(&_updateBLSFlags, sender, account.PasswordByFlag()) if err != nil { - return output.NewError(output.AddressError, "failed to get signed address", err) + return err } gasLimit := _gasLimitFlag.Value().(uint64) @@ -91,10 +113,12 @@ func stake2Update(args []string) error { return output.NewError(0, "failed to get nonce ", err) } - // TODO: derive blsPop from a user-supplied BLS private key. Pre-fork - // the PoP is optional; post-fork (EnforceBLSPoP active) the handler - // rejects an update that rotates the BLS key without a PoP. - s2u, err := action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKeyBytes, nil) + var s2u *action.CandidateUpdate + if len(blsPubKey) > 0 { + s2u, err = action.NewCandidateUpdateWithBLS(name, operatorAddrStr, rewardAddrStr, blsPubKey, blsPop) + } else { + s2u, err = action.NewCandidateUpdate(name, operatorAddrStr, rewardAddrStr) + } if err != nil { return output.NewError(output.InstantiationError, "failed to make a candidateUpdate instance", err) } From 143010dfe1571a953300af7e87ac380efdc64230 Mon Sep 17 00:00:00 2001 From: envestcc Date: Thu, 18 Jun 2026 10:24:53 +0800 Subject: [PATCH 9/9] test(staking): handler + e2e coverage for BLS PoP gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4854 had no handler-level integration coverage of the EnforceBLSPoP gate — only the underlying SignBLSPop / VerifyBLSPop unit tests in bls_pop_test.go — and no e2etest of the post-fork register path at all. Reviewers couldn't confirm the three handler sites actually fail-closed under the gate without running the full chain manually. ## Handler integration (action/protocol/staking/handlers_blspop_test.go) Three new test functions, 11 subcases: - TestHandleCandidateRegister_PoPGate - gate on + valid PoP → ReceiptStatus_Success, c.BLSPubKey persisted - gate on + empty PoP → ErrUnauthorizedOperator - gate on + PoP signed under wrong key → ErrUnauthorizedOperator - gate off + empty PoP → Success (pre-fork behaviour preserved) - TestHandleCandidateRegister_BLSPubKeyUniqueness - second candidate registering the same BLS pubkey with a valid PoP under its own owner → ErrCandidateConflict; the GetByBLSPubKey uniqueness check protects IIP-52's quorum-counting invariant against the "shared keypair" silent break - TestHandleCandidateUpdate_PoPGate - gate on + valid PoP under c.GetIdentifier() → rotation succeeds, new BLSPubKey in state - gate on + empty PoP → ErrUnauthorizedOperator - gate on + PoP signed under wrong candidateID → ErrUnauthorizedOperator - gate off + empty PoP → rotation succeeds (pre-fork compat) The fixture flips EnforceBLSPoP via genesis.ToBeEnabledBlockHeight (0 / math.MaxUint64) and forces XinguBlockHeight = 0 so the BLS registration codepath is reachable at the test block. ## e2etest (e2etest/native_staking_test.go) TestCandidateBLSPoP exercises the full chain pipeline — encode → submit → mint → handler → receipt → state — with four post-fork subcases plus a pre-fork backward-compat anchor: 1. Pre-fork register without PoP succeeds 2. Post-fork register with valid PoP succeeds, BlsPubKey persists 3. Post-fork register without PoP returns ErrUnauthorizedOperator 4. Post-fork update rotates BLS pubkey with valid PoP 5. Post-fork update without PoP returns ErrUnauthorizedOperator and leaves the previously rotated pubkey untouched in state Genesis wires ToBeEnabledBlockHeight == XinguBlockHeight so the BLS register path and the PoP gate activate together — the same shape the planned fork rollout will use. Pre-existing TestProtocol_FetchBucketAndValidate flake (master and this branch both flap ~1/3) is unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../protocol/staking/handlers_blspop_test.go | 332 ++++++++++++++++++ e2etest/native_staking_test.go | 175 +++++++++ 2 files changed, 507 insertions(+) create mode 100644 action/protocol/staking/handlers_blspop_test.go diff --git a/action/protocol/staking/handlers_blspop_test.go b/action/protocol/staking/handlers_blspop_test.go new file mode 100644 index 0000000000..1104d917b9 --- /dev/null +++ b/action/protocol/staking/handlers_blspop_test.go @@ -0,0 +1,332 @@ +// Copyright (c) 2026 IoTeX Foundation +// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability +// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed. +// This source code is governed by Apache License 2.0 that can be found in the LICENSE file. + +package staking + +import ( + "context" + "crypto/sha256" + "math" + "testing" + "time" + + "github.com/iotexproject/go-pkgs/crypto" + "github.com/iotexproject/iotex-address/address" + "github.com/iotexproject/iotex-proto/golang/iotextypes" + "github.com/mohae/deepcopy" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/iotexproject/iotex-core/v2/action" + "github.com/iotexproject/iotex-core/v2/action/protocol" + "github.com/iotexproject/iotex-core/v2/blockchain/genesis" + "github.com/iotexproject/iotex-core/v2/test/identityset" +) + +// blsKeyFromSeed generates a deterministic BLS keypair for tests. +func blsKeyFromSeed(t *testing.T, seed string) *crypto.BLS12381PrivateKey { + t.Helper() + h := sha256.Sum256([]byte(seed)) + sk, err := crypto.GenerateBLS12381PrivateKey(h[:]) + require.NoError(t, err) + return sk +} + +// genesisWithPoPGate returns a TestDefault genesis tuned for the PoP +// gate tests: XinguBlockHeight is forced to 0 so the BLS-register +// codepath is reachable (CandidateBLSPublicKey feature requires it), +// and ToBeEnabledBlockHeight controls whether EnforceBLSPoP is on. +func genesisWithPoPGate(gate bool) genesis.Genesis { + g := deepcopy.Copy(genesis.TestDefault()).(genesis.Genesis) + g.TsunamiBlockHeight = 0 + g.XinguBlockHeight = 0 + if gate { + g.ToBeEnabledBlockHeight = 0 + } else { + g.ToBeEnabledBlockHeight = math.MaxUint64 + } + return g +} + +// buildHandlerCtx wires the same context the handler expects, with the +// genesis configured for the PoP gate the caller wants. +func buildHandlerCtx(caller address.Address, gate bool, nonce uint64) context.Context { + ctx := protocol.WithActionCtx(context.Background(), protocol.ActionCtx{ + Caller: caller, + GasPrice: testGasPrice, + IntrinsicGas: 10000, + Nonce: nonce, + }) + ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{ + BlockHeight: 1, + BlockTimeStamp: time.Now(), + GasLimit: 1000000, + }) + ctx = protocol.WithBlockchainCtx(ctx, protocol.BlockchainCtx{Tip: protocol.TipInfo{}}) + ctx = genesis.WithGenesisContext(ctx, genesisWithPoPGate(gate)) + ctx = protocol.WithFeatureCtx(protocol.WithFeatureWithHeightCtx(ctx)) + return ctx +} + +// runRegisterWithBLS runs handleCandidateRegister (via Protocol.Handle) +// for a candidate registration that carries the supplied BLS material. +// Returns the receipt so the caller can assert on Status. +func runRegisterWithBLS(t *testing.T, p *Protocol, sm protocol.StateManager, + caller, owner address.Address, nonce uint64, name string, + blsPubKey, blsPop []byte, gate bool) *action.Receipt { + t.Helper() + require := require.New(t) + require.NoError(setupAccount(sm, caller, 100_000_000)) + + cr, err := action.NewCandidateRegisterWithBLS( + name, + identityset.Address(28).String(), + identityset.Address(29).String(), + owner.String(), + "1200000000000000000000000", + uint32(10000), + false, + blsPubKey, blsPop, nil, + ) + require.NoError(err) + elp := builder.SetNonce(nonce).SetGasLimit(1_000_000). + SetGasPrice(testGasPrice).SetAction(cr).Build() + + ctx := buildHandlerCtx(caller, gate, nonce) + require.NoError(p.Validate(ctx, elp, sm)) + r, err := p.Handle(ctx, elp, sm) + require.NoError(err) + return r +} + +// TestHandleCandidateRegister_PoPGate covers the post-fork PoP +// enforcement at the register handler. The unit tests for SignBLSPop / +// VerifyBLSPop establish the cryptographic property; this test wires +// it through the handler and confirms the gate semantics: +// +// - gate ON + valid PoP → ReceiptStatus_Success +// - gate ON + empty PoP → ReceiptStatus_ErrUnauthorizedOperator +// - gate ON + invalid PoP → ReceiptStatus_ErrUnauthorizedOperator +// - gate OFF + empty PoP → ReceiptStatus_Success (pre-fork +// behaviour preserved — BLS registration works without PoP) +func TestHandleCandidateRegister_PoPGate(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + owner := identityset.Address(30) + caller := identityset.Address(27) // initAll uses 27 as the caller fixture + + sk := blsKeyFromSeed(t, "register-gate") + pk := sk.PublicKey().Bytes() + + t.Run("gate on, valid PoP → Success", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + pop, err := SignBLSPop(sk, owner) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "popok", pk, pop, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "handler should accept a valid PoP under EnforceBLSPoP") + + // confirm the BLS pubkey actually landed in state + csm, err := NewCandidateStateManager(sm) + require.NoError(err) + cand := csm.GetByOwner(owner) + require.NotNil(cand) + require.Equal(pk, cand.BLSPubKey) + }) + + t.Run("gate on, empty PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "nopop", pk, nil, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "handler must reject register with empty PoP once the gate is on") + }) + + t.Run("gate on, invalid PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + // PoP shape-correct but signed by a different key — verifier + // rejects because Verify(pk, ...) doesn't accept a sig produced + // under sk_attacker. + attackerSK := blsKeyFromSeed(t, "attacker") + forged, err := attackerSK.Sign(SignBLSPopMustRoot(t, owner)) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "badpop", pk, forged, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "handler must reject register with PoP that doesn't verify under blsPubKey") + }) + + t.Run("gate off, empty PoP → Success (pre-fork compat)", func(t *testing.T) { + sm, p, _, _ := initAll(t, ctrl) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "preforkok", pk, nil, false) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "gate off → PoP is optional, pre-fork behaviour preserved") + }) +} + +// TestHandleCandidateRegister_BLSPubKeyUniqueness locks in the second +// post-fork invariant: two delegates cannot share a BLS pubkey, since +// IIP-52 FastAggregateVerify dedupes the pubkey set but the signer +// bitmap doesn't. Without the GetByBLSPubKey check the second +// registration would be silently accepted, breaking quorum counting. +func TestHandleCandidateRegister_BLSPubKeyUniqueness(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + sm, p, _, _ := initAll(t, ctrl) + + sk := blsKeyFromSeed(t, "shared") + pk := sk.PublicKey().Bytes() + + // First registration — owner A — succeeds. + ownerA := identityset.Address(30) + popA, err := SignBLSPop(sk, ownerA) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, identityset.Address(27), ownerA, 1, "canda", pk, popA, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + + // Second registration — owner B, same pubkey, valid PoP signed + // under ownerB so PoP itself passes — must be rejected by the + // uniqueness check. + ownerB := identityset.Address(31) + popB, err := SignBLSPop(sk, ownerB) + require.NoError(err) + r = runRegisterWithBLS(t, p, sm, identityset.Address(28), ownerB, 1, "candb", pk, popB, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrCandidateConflict, r.Status, + "a second candidate must not be allowed to register the same BLS pubkey") +} + +// SignBLSPopMustRoot builds the signing root for a candidate without +// invoking VerifyBLSPop — used by the invalid-PoP case where we want +// to sign with the wrong key. +func SignBLSPopMustRoot(t *testing.T, candidateID address.Address) []byte { + t.Helper() + root := BLSPopSigningRoot(candidateID) + require.NotNil(t, root) + return root +} + +// TestHandleCandidateUpdate_PoPGate covers the update path's PoP gate. +// Tested through handleCandidateUpdate (the owner-as-caller branch). +// The Operator-as-caller branch is structurally identical and shares +// the same VerifyBLSPop + GetByBLSPubKey calls — covered by Unit and +// by the rogue-key regression in bls_pop_test.go. +func TestHandleCandidateUpdate_PoPGate(t *testing.T) { + require := require.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + owner := identityset.Address(30) + caller := identityset.Address(27) + + // Set up state with an existing candidate owned by `owner`. + setupCand := func() (protocol.StateManager, *Protocol) { + sm, p, _, _ := initAll(t, ctrl) + sk0 := blsKeyFromSeed(t, "original") + pk0 := sk0.PublicKey().Bytes() + pop0, err := SignBLSPop(sk0, owner) + require.NoError(err) + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "mycand", pk0, pop0, true) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + return sm, p + } + + runUpdate := func(sm protocol.StateManager, p *Protocol, callerAddr address.Address, + newPK, newPoP []byte, nonce uint64, gate bool) *action.Receipt { + require.NoError(setupAccount(sm, callerAddr, 100_000_000)) + cu, err := action.NewCandidateUpdateWithBLS( + "mycand", + identityset.Address(28).String(), + identityset.Address(29).String(), + newPK, newPoP, + ) + require.NoError(err) + elp := builder.SetNonce(nonce).SetGasLimit(1_000_000). + SetGasPrice(testGasPrice).SetAction(cu).Build() + ctx := buildHandlerCtx(callerAddr, gate, nonce) + require.NoError(p.Validate(ctx, elp, sm)) + r, err := p.Handle(ctx, elp, sm) + require.NoError(err) + return r + } + + t.Run("gate on, valid PoP → rotation succeeds, new BLSPubKey in state", func(t *testing.T) { + sm, p := setupCand() + csm0, err := NewCandidateStateManager(sm) + require.NoError(err) + c0 := csm0.GetByOwner(owner) + require.NotNil(c0) + + newSK := blsKeyFromSeed(t, "rotation-target") + newPK := newSK.PublicKey().Bytes() + // PoP is signed under the candidate's IDENTIFIER (not the + // current owner) — the property locked in by + // TestBLSPop_StableAcrossOwnershipTransfer in bls_pop_test.go. + newPoP, err := SignBLSPop(newSK, c0.GetIdentifier()) + require.NoError(err) + + r := runUpdate(sm, p, owner, newPK, newPoP, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "update with valid PoP under c.GetIdentifier() must succeed") + + csm, err := NewCandidateStateManager(sm) + require.NoError(err) + c := csm.GetByOwner(owner) + require.NotNil(c) + require.Equal(newPK, c.BLSPubKey, "BLSPubKey must be rotated in state") + }) + + t.Run("gate on, empty PoP → ErrUnauthorizedOperator", func(t *testing.T) { + sm, p := setupCand() + newSK := blsKeyFromSeed(t, "rotation-target-nopop") + newPK := newSK.PublicKey().Bytes() + r := runUpdate(sm, p, owner, newPK, nil, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "empty PoP on rotation must be rejected under EnforceBLSPoP") + }) + + t.Run("gate on, PoP bound to wrong candidateID → reject", func(t *testing.T) { + sm, p := setupCand() + newSK := blsKeyFromSeed(t, "rotation-target-wrong-id") + newPK := newSK.PublicKey().Bytes() + // PoP signed under the WRONG candidateID — the cross-candidate + // replay defence at the binding layer. + wrongID := identityset.Address(33) + wrongPoP, err := SignBLSPop(newSK, wrongID) + require.NoError(err) + r := runUpdate(sm, p, owner, newPK, wrongPoP, 1, true) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, r.Status, + "PoP signed under the wrong candidateID must be rejected") + }) + + t.Run("gate off, empty PoP → rotation succeeds (pre-fork compat)", func(t *testing.T) { + // For the gate-off variant we register without PoP, then + // rotate without PoP, both under gate-off — confirming the + // existing behaviour stays intact for pre-fork blocks. + sm, p, _, _ := initAll(t, ctrl) + require.NoError(setupAccount(sm, caller, 100_000_000)) + sk0 := blsKeyFromSeed(t, "prefork-original") + pk0 := sk0.PublicKey().Bytes() + r := runRegisterWithBLS(t, p, sm, caller, owner, 1, "mycand", pk0, nil, false) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status) + + newSK := blsKeyFromSeed(t, "prefork-rotation") + newPK := newSK.PublicKey().Bytes() + r = runUpdate(sm, p, owner, newPK, nil, 1, false) + require.NotNil(r) + require.EqualValues(iotextypes.ReceiptStatus_Success, r.Status, + "gate off → update without PoP must continue to work") + }) +} diff --git a/e2etest/native_staking_test.go b/e2etest/native_staking_test.go index 0c141ee1d6..3afd039eea 100644 --- a/e2etest/native_staking_test.go +++ b/e2etest/native_staking_test.go @@ -1671,6 +1671,181 @@ func TestCandidateBLSPublicKey(t *testing.T) { }) } +// TestCandidateBLSPoP exercises the post-fork BLS proof-of-possession +// gate end-to-end through the e2etest harness. Five subcases: +// +// 1. Register without PoP pre-fork — handler accepts (backward compat). +// 2. Register with valid PoP post-fork — handler accepts, candidate +// state carries the BLS pubkey. +// 3. Register without PoP post-fork — handler returns +// ErrUnauthorizedOperator and no candidate is created. +// 4. Update with valid PoP post-fork — BLS pubkey rotates in state. +// 5. Update without PoP post-fork — handler rejects, BLS pubkey +// remains unchanged. +// +// The gate is wired via genesis.ToBeEnabledBlockHeight = XinguBlockHeight, +// so XinguBlockHeight is both the activation point for BLS-bearing +// registration and the activation point for EnforceBLSPoP. Pre-fork +// blocks have neither feature active; post-fork blocks have both. +func TestCandidateBLSPoP(t *testing.T) { + require := require.New(t) + cfg := initCfg(require) + cfg.Genesis.WakeBlockHeight = 1 + cfg.Genesis.XinguBlockHeight = 10 + cfg.Genesis.XinguBetaBlockHeight = 11 + cfg.Genesis.YapBlockHeight = 20 + // Wire EnforceBLSPoP to activate alongside the BLS register path + // itself. Without this, the post-fork PoP gate is never reached + // during the test. + cfg.Genesis.ToBeEnabledBlockHeight = uint64(cfg.Genesis.XinguBlockHeight) + cfg.Genesis.SystemStakingContractAddress = "" + cfg.Genesis.SystemStakingContractV2Address = "" + cfg.Genesis.SystemStakingContractV3Address = "" + cfg.DardanellesUpgrade.BlockInterval = time.Second * 8640 + cfg.Plugins[config.GatewayPlugin] = nil + cfg.API.GRPCPort = 14014 + rand.Intn(3000) + cfg.API.HTTPPort = cfg.API.GRPCPort + 1000 + cfg.API.WebSocketPort = cfg.API.HTTPPort + 1000 + test := newE2ETest(t, cfg) + + var ( + chainID = test.cfg.Chain.ID + registerAmount = unit.ConvertIotxToRau(1200000) + // Distinct owners so each subcase registers a fresh candidate + // (the register handler rejects re-registration on the same + // owner that already has self-stake). + preForkOwnerID = 3 + preForkOpID = 1 + postForkOwnerID = 4 + postForkOpID = 2 + rejectOwnerID = 5 + ) + + postForkBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(9).Bytes()) + require.NoError(err) + rejectBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(10).Bytes()) + require.NoError(err) + + genTransferActionsWithPrice := func(n int, price *big.Int) []*actionWithTime { + acts := make([]*actionWithTime, n) + for i := 0; i < n; i++ { + acts[i] = &actionWithTime{mustNoErr(action.SignedTransfer(identityset.Address(1).String(), identityset.PrivateKey(2), test.nonceMgr.pop(identityset.Address(2).String()), unit.ConvertIotxToRau(1), nil, gasLimit, price, action.WithChainID(chainID))), time.Now()} + } + return acts + } + + // --- Pre-fork: register without PoP succeeds --- + test.run([]*testcase{ + { + name: "pre-fork register without PoP succeeds (backward compat)", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegister(test.nonceMgr.pop(identityset.Address(preForkOwnerID).String()), "preforkcand", identityset.Address(preForkOpID).String(), identityset.Address(1).String(), identityset.Address(preForkOwnerID).String(), registerAmount.String(), 1, true, nil, gasLimit, gasPrice1559, identityset.PrivateKey(preForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "pre-fork register without BLS / PoP must succeed") + }, + }, + }) + + // Advance past the XinguBlockHeight + ToBeEnabledBlockHeight gate. + height, err := test.cs.BlockDAO().Height() + require.NoError(err) + advance := int(cfg.Genesis.XinguBlockHeight) - int(height) + if advance < 1 { + advance = 1 + } + + // --- Post-fork: register with valid PoP succeeds; BLS pubkey + // lands in candidate state --- + postForkOwnerAddr, err := address.FromString(identityset.Address(postForkOwnerID).String()) + require.NoError(err) + postForkPoP, err := staking.SignBLSPop(postForkBLSSk, postForkOwnerAddr) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork register with valid PoP succeeds", + preActs: genTransferActionsWithPrice(advance, gasPrice1559), + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), identityset.Address(postForkOwnerID).String(), registerAmount.String(), 1, true, postForkBLSSk.PublicKey().Bytes(), postForkPoP, nil, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "valid PoP must be accepted post-fork") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(postForkBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must be persisted on the candidate") + }, + }, + }) + + // --- Post-fork: register WITHOUT PoP is rejected --- + test.run([]*testcase{ + { + name: "post-fork register without PoP is rejected", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateRegisterWithBLS(test.nonceMgr.pop(identityset.Address(rejectOwnerID).String()), "noptest", identityset.Address(rejectOwnerID).String(), identityset.Address(2).String(), identityset.Address(rejectOwnerID).String(), registerAmount.String(), 1, true, rejectBLSSk.PublicKey().Bytes(), nil, nil, gasLimit, gasPrice1559, identityset.PrivateKey(rejectOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, blk.Receipts[0].Status, + "missing PoP under EnforceBLSPoP must fail with ErrUnauthorizedOperator") + }, + }, + }) + + // --- Post-fork: update with valid PoP rotates BLS pubkey --- + newBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(11).Bytes()) + require.NoError(err) + // The candidate for update is `postforkok`, owned by postForkOwnerID. + // PoP must bind to the candidate's identifier; for non-collision + // registrations the identifier is the owner address itself. + rotPoP, err := staking.SignBLSPop(newBLSSk, postForkOwnerAddr) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork update rotates BLS pubkey with valid PoP", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), newBLSSk.PublicKey().Bytes(), rotPoP, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_Success, blk.Receipts[0].Status, + "update with valid PoP must succeed") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(newBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must be rotated in state") + }, + }, + }) + + // --- Post-fork: update WITHOUT PoP is rejected; BLS pubkey unchanged --- + stalerBLSSk, err := crypto.GenerateBLS12381PrivateKey(identityset.PrivateKey(12).Bytes()) + require.NoError(err) + test.run([]*testcase{ + { + name: "post-fork update without PoP is rejected", + acts: []*actionWithTime{ + {mustNoErr(action.SignedCandidateUpdateWithBLS(test.nonceMgr.pop(identityset.Address(postForkOwnerID).String()), "postforkok", identityset.Address(postForkOpID).String(), identityset.Address(2).String(), stalerBLSSk.PublicKey().Bytes(), nil, gasLimit, gasPrice1559, identityset.PrivateKey(postForkOwnerID), action.WithChainID(chainID))), time.Now()}, + }, + blockExpect: func(test *e2etest, blk *block.Block, err error) { + require.NoError(err) + require.EqualValues(iotextypes.ReceiptStatus_ErrUnauthorizedOperator, blk.Receipts[0].Status, + "missing PoP on rotation under EnforceBLSPoP must fail") + cand, err := test.getCandidateByName("postforkok") + require.NoError(err) + require.Equal(newBLSSk.PublicKey().Bytes(), cand.BlsPubKey, + "BLS pubkey must remain at the previous rotated value, not the rejected one") + }, + }, + }) + +} + func parseNativeStakedBucketIndex(receipt *action.Receipt) []uint64 { var bucketIndexes []uint64 for _, log := range receipt.Logs() {