Stop unusable keys from freezing the onblock schedule rebuild - #632
Conversation
set_proposed_producers_common validated every key in every authority. The system contract rebuilds the producer schedule from its own tables inside onblock, and update_ranked_producers writes last_producer_schedule_update before proposing -- a rejection rolls that write back with the transaction, so the 120-slot gate re-fires on the next block and fails again. One producer holding a key that cannot sign froze schedule updates permanently. Neither key check decides anything here. block_state::verify_signee rejects any key recovered from a block signature that is not K1 or R1, so a producer holding another type can never sign with it. A key that decodes to no curve point is recovered from no signature at all, so it never matches in keys_satisfy_and_relevant. A producer holding either burns its rounds; the chain keeps publishing schedules. Upstream dropped the same checks from the authority format for the same reason, and kept them on the legacy producer_key format only to avoid a consensus change on an already-live intrinsic. That constraint does not apply here, so both formats are lenient and there is one rule rather than two that differ by packing format. r1::public_key_shim::valid() could not answer for the case that matters: the r1::public_key constructor throws when the point does not decode, so the predicate raised a bare fc::exception past the assert meant to catch it. It returns false now, independent of the schedule path. The K1 shim, whose constructor only copies, is documented as the all-zero test it actually is. The K1/R1 rule had no coverage on the signing side, where it now lives exclusively. A block re-signed with an EM key covers it.
regfinkey admitted the BLS identity point. A proof of possession cannot screen it out: bls_pop_verify computes e(-g1, sig) * e(pk, H(pk)), and both terms are 1 when the points are the identity, so an all-zero key with an all-zero proof verifies. Registered as a producer's first key it auto-activates without proposing a policy, so nothing looks at it again until the schedule rebuild does. That rebuild runs inside onblock. set_finalizers rejects the identity point there, rolling back the rebuild timestamp with the rest of the transaction, so the 120-slot gate re-fires and fails again on every block that follows. One producer registering that key stops both the producer schedule and the finalizer policy from updating, permanently. The rejection itself has to stand. The identity point is not a key that cannot vote, it is a key whose votes anyone can cast: aggregate_public_keys treats it as a no-op on the aggregate while verify_weights still counts its weight from the bitset, so a quorum certificate can claim it voted and verify against the honest signatures alone. So refuse it at regfinkey, the only action that admits new key material, along with any key that is not a point on the curve -- set_finalizers raises while deserializing one of those, before the policy is validated at all. delfinkey stays lenient so a key registered before this check can still be removed. set_finalizers reported an off-curve key as a bare fc::exception, because the bls_public_key constructor raises outside the catch that converts policy errors. It now reports like every other input error on that path.
a50abce to
0e965e2
Compare
|
[Medium] Reject an absent BLS payload while retaining schedule-key leniency wire-sysio/libraries/chain/webassembly/privileged.cpp Lines 65 to 72 in 0e965e2 The relaxed common path now accepts every public-key variant without checking its representation. BLS public_key_shim stores its bytes behind a shared_ptr, and raw deserialization permits that pointer to be absent. A one-key authority with this representation passes proposer_policy::validate because the first uniqueness-set insertion performs no comparison. Both legacy and authority packing forms reach this common path. On the default node configuration, schedule logging calls the BLS shim to_string and dereferences the absent pointer, normally terminating the process rather than raising a controlled fc exception. With info logging disabled, a later equality comparison against the same schedule dereferences it as well. Please reject an absent BLS payload, or enforce non-nullness during deserialization, without restoring K1/R1-only or curve-validity restrictions. A regression test should cover both packing formats. Reaching this path requires privileged intrinsic input, which is why I consider it medium severity. |
huangminghuang
left a comment
There was a problem hiding this comment.
Requesting changes based on the medium-severity issue described here: #632 (comment)
fc reflects the BLS shims' shared_ptr member and packs a presence flag ahead of it, so a false flag unpacks to a null pointer. valid(), to_string(), serialize() and unwrapped() all dereference it unconditionally, terminating the process rather than raising. Both constructors allocate, so deserialization is the only way to reach that state. public_key_shim matters here because dropping the key-type check on the proposed schedule path lets a BLS key reach it, and a one-key authority passes proposer_policy::validate untouched -- the first insertion into the uniqueness set compares nothing -- so the null survives to whatever logs or compares the schedule. signature_shim has the same representation and sits in the signature variant used for transaction signatures, which arrive from RPC and peers. Both reject an absent payload in reflector_init, which fc calls after unpacking a reflected type, turning it into a controlled exception where the bytes are read.
|
Confirmed and fixed in a446bad. Fixed at the representation rather than the schedule call site: Guards on the constructors turned out to be unnecessary: both allocate, so with deserialization guarded there is no null source to copy from. Tests: crafted bytes for a BLS public key and a BLS signature must throw rather than crash, with a real key still round-tripping; and both packing formats at the schedule layer — |
huangminghuang
left a comment
There was a problem hiding this comment.
Blocking findings remain in the finalizer-key fix: the new check does not enforce G1 subgroup membership, and retained pre-upgrade keys can still become active without validation. I also noted that the schedule liveness regression stops before block publication. The branch currently conflicts with master; conflict resolution must preserve the current master constraints as well.
…ity-key-leniency # Conflicts: # contracts/sysio.system/sysio.system.wasm
A canonical on-curve check is not key validation. IETF's BLS KeyValidate is deserialize, reject the identity, and check r-order subgroup membership; only the first two were enforced. A small-order point such as affine (0, 2) is canonical, on the curve, and not the identity, yet its order is coprime to r, so it pairs to one against every G2 point and bls_pop_verify accepts it with an identity proof. Enforced in the two places that matter. fc::crypto::bls::public_key rejects a point outside the subgroup where the key is constructed, covering every path that deserializes BLS material. regfinkey rejects it as [r]P == identity before the key enters the table, because set_finalizers is reached from the schedule rebuild inside onblock, where throwing would stop every later rebuild. Key generation is unaffected: a public key is [sk]G, which is always in the subgroup, so no key these tools produce can fail either check.
set_producer_schedule only pushes the action. The policy is assembled, logged and diffed when the block is finalized, so the tests proved intrinsic acceptance and nothing about publication -- and that logging path is where an unusable key would have been dereferenced. Both now produce the block and assert the proposer policy diff. Adds the rank-walk regression this fix exists for. A scheduled producer re-registers with an undecodable R1 key, which registration admits because it is an R1 key and is not all zero, and the walk must still publish a schedule. It advances one block at a time and stops at the proposal: past that the policy activates, and a harness that signs every block itself cannot sign a slot belonging to a producer whose key nothing can sign with, which is the intended consequence of admitting the key rather than a chain failure.
|
Merged master and rebuilt the contracts for the wasm conflict, which was the only conflicting file. Subgroup [P1] — confirmed and fixed in 08b44d2. Reproduced it first: with the check disabled, Worth recording that key generation cannot produce one: a public key is Finish the block [P2] — you were right, and more than stated. Block assembly is where the logging from your earlier finding happens, so the tests were missing exactly the path that mattered. Both now produce the block and assert Added the rank-walk regression in d05e18d. A scheduled producer re-registers with an undecodable R1 key, which registration admits because master's check is a non-zero-bytes test and the key is a non-zero R1 point, and the walk must still publish. Verified load-bearing: with the removed validity check restored, no schedule publishes across 200 blocks; with the fix, one lands after 58. It stops at the proposal deliberately — past that the policy activates, and a harness that signs every block itself cannot sign a slot belonging to a producer whose key nothing can sign with. That is the intended consequence of admitting the key, not a chain failure. Retained keys [P1] — I do not think this one applies here. There is no chain and no existing data, so no pre-upgrade rows can exist, and Where I agree: it stops being moot the moment validation rules change again on a running chain, since keys registered between two rule changes are exactly the retained-unvalidated case. That seems worth building before launch, as upgrade-safety machinery rather than as part of this PR. Happy to be persuaded otherwise if you see a path to it that does not depend on pre-existing rows. |
huangminghuang
left a comment
There was a problem hiding this comment.
Re-reviewed current head d05e18d22969cae97e6a2349e43c73e7de63b0f9. The subgroup-membership fix and the schedule-publication regressions now look correct, and I resolved those two threads. One blocking liveness/upgrade issue remains: retained pre-upgrade finalizer keys can still become active without validation, as detailed in #632 (comment). The branch also now conflicts with current master in the generated sysio.system.wasm; resolving it must rebuild the contract with both source changes. Please also refresh the PR description, which still describes curve-only validation and registration as the sole admission path.
…ity-key-leniency # Conflicts: # contracts/sysio.system/sysio.system.wasm
|
Merged latest master and rebuilt the contract; the wasm was again the only conflict. Description refreshed — it described curve-only validation and was loose about admission paths, both now corrected. Retained keys — still declining, and here is the concrete reason rather than just "pre-launch". You are right about the mechanism: the central constructor does make a later
The window you are pointing at — an old contract accepting a key a new node would reject — requires a chain already running a prior system contract version. There is no such chain. I also cannot write the retained-state regression you asked for. With |
huangminghuang
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 829d76dda9027a89c16176cb5262662ae77abb34, excluding the retained pre-upgrade-key concern from the approval decision as requested. No other issues found. The merged sources and rebuilt sysio.system.wasm preserve both the WIRE-360 onlinkauth removal and this PR's identity/on-curve/subgroup validation, the producer-schedule paths and publication regressions look correct, the branch is mergeable with current master, and all required checks passed.
update_ranked_producerswriteslast_producer_schedule_updateand then proposes both a producer schedule and a finalizer policy, all insideonblock. Anything that throws in there rolls that write back with the rest of the transaction, so the 120-slot gate re-fires on the next block and fails again — permanently, on every block after. Two different keys could trigger it, and they need opposite fixes. Relaxing the first also let a third key type reach the path, where it turned out to dereference null.Producer keys: the checks go
set_proposed_producers_commonvalidated every key in every authority. Neither check decides anything there:block_state::verify_signeealready assertscontains_type(k1, r1)on every key recovered from a block signature, so a producer holding another type can never sign with it.keys_satisfy_and_relevant.A producer holding an unusable block-signing key burns its rounds, which is already ranking's problem via
consecutive_missed_roundsand demotion. Both checks are dropped, on both packing formats.Upstream removed the same checks from the authority format in EOSIO/eos#8021 — "to allow contracts to call
set_proposed_producers_exin a manner that gives the contract confidence that the intrinsic will not abort the transaction" — and kept them on the legacyproducer_keyformat only "to avoid introducing an unintended consensus change" on an intrinsic already live on mainnet. That constraint does not apply pre-launch, so both formats are lenient and there is one rule rather than two that differ by packing format. The same PR notes the check never established what it appears to: a valid curve point whose private key nobody holds passes it just the same.r1::public_key_shim::valid()threw rather than answering — ther1::public_keyconstructor raises wheno2i_ECPublicKeyrejects the point, so a barefc::exceptionescaped past the assert meant to catch it. It returns false now, independent of the schedule path.Finalizer keys: the check stays, and moves earlier
regfinkeyadmitted the BLS identity point. A proof of possession cannot screen it out —bls_pop_verifycomputese(-g₁, sig)·e(pk, H(pk)), and both terms are 1 when the points are the identity, so an all-zero key with an all-zero proof verifies. Registered as a producer's first key it auto-activates without proposing a policy, and nothing looks at it again until the rebuild does, at which pointset_finalizersrejects it and the freeze above begins.That rejection has to stand. The identity point is not a key that cannot vote, it is a key whose votes anyone can cast:
aggregate_public_keystreats it as a no-op on the aggregate whileverify_weightsstill counts its weight from the bitset, so a quorum certificate can claim it voted and verify against the honest signatures alone. Free weight toward the finality threshold, for anyone who can see the policy.So it is refused at
regfinkeyinstead, which is the only action that writes new key material into_finalizer_keys—actfinkeyanddelfinkeycan only move or remove a key already registered there. Alongside the identity, two further conditions are rejected: bytes that are not a canonical point on the curve, which makeset_finalizersraise while deserializing them before the policy is validated at all, and points outside the r-order subgroup.Subgroup membership is a separate test from being on the curve, and the distinction matters. A small-order point such as affine
(0, 2)is canonical, on the curve, and not the identity, yet its order is coprime tor, so it pairs to one against every G2 point —bls_pop_verifyaccepts it with an identity proof exactly as it accepts the identity key. IETF's BLSKeyValidatespecifies deserialize, reject the identity, and check the subgroup; only the first two were enforced.The subgroup check is also applied centrally, in
fc::crypto::bls::public_key, so every path that deserializes BLS material gets it rather than the contract alone. Key generation is unaffected either way: a public key is[sk]G, which is always in the subgroup, so no key our tooling produces can fail these checks — reaching them requires hand-crafted bytes.delfinkeystays lenient so a key registered before these checks can still be removed.set_finalizersreported an off-curve key as a barefc::exception, because thebls_public_keyconstructor raises outside the catch that converts policy errors. It now reports like every other input error on that path.An absent BLS payload
Dropping the key-type check lets a BLS key reach the schedule path, and
public_key_shimkeeps its payload behind ashared_ptrthat fc reflects directly — so a false presence flag unpacks to null, andvalid(),to_string(),serialize()andunwrapped()all dereference it. A one-key authority slips pastproposer_policy::validateuntouched, because the first insertion into the uniqueness set compares nothing. The result is process termination rather than a controlled exception.Fixed at the representation rather than the call site:
reflector_init()rejects an absent payload where the bytes are read. That covers every path that deserializes BLS material, not just this one —signature_shimhas the same shape and sits in thefc::crypto::signaturevariant used for transaction signatures, so it was reachable from RPC and peer input independently of producer schedules. The wire format is unchanged and no key-type restriction is restored.Guards on the shims' constructors are not needed: both allocate, so with deserialization guarded there is no null source for a copy to propagate.
Notes
is_feature_activeis hard-false), so it lands pre-launch.protocol_feature_tests/producer_keysasserted that a proposed schedule rejects WA/EM/ED/BLS keys; it now asserts they are accepted. The K1/R1 rule had no coverage on the signing side, where it now lives exclusively — a block re-signed with an EM key covers it.bls_pop_verifycheck the pairing return code rather than comparing an uninitialized buffer. It is not required by this PR, which validates the point explicitly before calling it, but it closes the same gap for every other caller. Thesysio.system.wasmcommitted here is unaffected: CI resolves CDT from a published release asset rather than from master, and builds the system contracts only on tag builds, so a rebuild is due once a wire-cdt release carrying Specify a specific version for all dependencies #121 is published.