-
Notifications
You must be signed in to change notification settings - Fork 11
WNS-28 Fix WIRE-349 creator chain-kind validation #601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,13 +105,28 @@ std::optional<opp::types::ChainKind> chain_kind_for_code(sysio::slug_name chain_ | |
| return tbl.get(pk).kind; | ||
| } | ||
|
|
||
| /// Soft-gate never-throw msgch handlers before they can emit a queueout to an | ||
| /// unregistered destination chain. `sysio.msgch::queueout` fails loudly for | ||
| /// direct callers, but dispatch callbacks must log-and-skip instead of | ||
| /// aborting the consensus-tipping delivery transaction. | ||
| bool registered_chain_or_skip(sysio::slug_name chain_code, const char* handler) { | ||
| if (chain_kind_for_code(chain_code).has_value()) return true; | ||
| /// Resolve the authoritative kind while soft-gating never-throw msgch handlers | ||
| /// before they can emit a queueout to an unregistered destination chain. | ||
| /// `sysio.msgch::queueout` fails loudly for direct callers, but dispatch | ||
| /// callbacks must log-and-skip instead of aborting the consensus-tipping | ||
| /// delivery transaction. | ||
| std::optional<opp::types::ChainKind> | ||
| registered_chain_kind_or_skip(sysio::slug_name chain_code, const char* handler) { | ||
| auto kind = chain_kind_for_code(chain_code); | ||
| if (kind.has_value()) return kind; | ||
| sysio::print(handler, ": chain_code is not registered; skipping\n"); | ||
| return std::nullopt; | ||
| } | ||
|
|
||
| /// Validate the raw creator address against the authoritative registry kind. | ||
| /// User-created reserves originate on supported external outposts only: EVM | ||
| /// addresses are 20 bytes and SVM addresses are 32 bytes. Unknown/depot kinds | ||
| /// are not valid creator-address domains for this handler. | ||
| bool creator_address_matches_kind(opp::types::ChainKind kind, | ||
| const std::vector<char>& address) { | ||
| using opp::types::ChainKind; | ||
| if (kind == ChainKind::CHAIN_KIND_EVM) return address.size() == 20; | ||
| if (kind == ChainKind::CHAIN_KIND_SVM) return address.size() == 32; | ||
| return false; | ||
| } | ||
|
|
||
|
|
@@ -410,7 +425,9 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, | |
| bool is_private, | ||
| std::vector<char> creator_pub_key) { | ||
| require_auth(MSGCH_ACCOUNT); | ||
| if (!registered_chain_or_skip(chain_code, "oncrtreserve")) return; | ||
| const auto expected_chain_kind = | ||
| registered_chain_kind_or_skip(chain_code, "oncrtreserve"); | ||
| if (!expected_chain_kind.has_value()) return; | ||
|
|
||
| // Soft-validate; silent skip per feedback_opp_handlers_never_throw. | ||
| if (connector_weight_bps == 0 || connector_weight_bps > MAX_CONNECTOR_WEIGHT_BPS) { | ||
|
|
@@ -424,6 +441,13 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, | |
| // the SAME cancel/refund path as an unlinked creator below (insert a CANCELLED | ||
| // row + queue RESERVE_CREATE_CANCELLED), idempotently. | ||
| const bool invalid_amount = (external_token_amount == 0 || requested_wire_amount == 0); | ||
| // `chain_code` is registry-owned and authoritative. The attestation also | ||
| // carries a creator kind, but that redundant, externally supplied value | ||
| // must not select a different public-key variant or produce a reserve that | ||
| // `matchreserve` can never match against the registered chain kind. | ||
| const bool creator_chain_kind_mismatch = creator_chain_kind != *expected_chain_kind; | ||
| const bool invalid_creator_address = | ||
| !creator_address_matches_kind(*expected_chain_kind, creator_chain_addr); | ||
| // Same `sysio`-billed metadata bound the privileged registrations enforce with | ||
| // `check_metadata`, asked the non-throwing way: this handler must never abort, so an | ||
| // over-bound string joins the reject/refund path below rather than reverting dispatch. | ||
|
|
@@ -454,22 +478,22 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, | |
| } | ||
|
|
||
| opp::types::ChainAddress creator; | ||
| creator.kind = creator_chain_kind; | ||
| creator.kind = *expected_chain_kind; | ||
|
huangminghuang marked this conversation as resolved.
|
||
| creator.address = std::move(creator_chain_addr); | ||
|
|
||
| // Create gating: the creator must already be authex-linked to a WIRE | ||
| // account ("the only requirement to create a reserve"). Reconstruct the | ||
| // creator's key variant and probe `sysio.authex::links.bypubkey`. On | ||
| // any failure — malformed key bytes, no link, an invalid amount, OR | ||
| // over-bound metadata — reject by inserting a | ||
| // any failure — a chain-kind mismatch, malformed address/key bytes, no | ||
| // link, an invalid amount, OR over-bound metadata — reject by inserting a | ||
| // CANCELLED row (for refund idempotency) and queueing | ||
| // RESERVE_CREATE_CANCELLED so the outpost refunds the creator's escrow. | ||
| // The CANCELLED row does NOT permanently burn the identity: a later, | ||
| // properly-linked creator reclaims it via the reclaim branch below | ||
| // (prevents namespace squatting). Never throws. | ||
| std::vector<char> canonical_creator_key; | ||
| { | ||
| auto pk_variant = pubkey_from_raw(creator_chain_kind, creator_pub_key, creator.address); | ||
| auto pk_variant = pubkey_from_raw(*expected_chain_kind, creator_pub_key, creator.address); | ||
| bool linked = false; | ||
| if (pk_variant) { | ||
| sysio::authex::links_t links(AUTHEX_ACCOUNT); | ||
|
|
@@ -479,7 +503,8 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, | |
| canonical_creator_key = sysio::pubkey_to_bytes(*pk_variant); | ||
| } | ||
| } | ||
| if (!linked || invalid_amount || oversized_metadata) { | ||
| if (creator_chain_kind_mismatch || invalid_creator_address || !linked || | ||
| invalid_amount || oversized_metadata) { | ||
| // A CANCELLED row already standing means this is a re-relay of the same | ||
| // rejected create (an unlinked squatter OR an invalid amount). Leave it | ||
| // and do NOT queue a second refund — the refund was queued when the row | ||
|
|
@@ -491,7 +516,12 @@ void reserve::oncrtreserve(sysio::slug_name chain_code, | |
| return; | ||
| } | ||
| sysio::print("oncrtreserve: rejecting with RESERVE_CREATE_CANCELLED " | ||
| "(invalid amount, over-bound metadata, or unlinked / malformed creator key)\n"); | ||
| "(creator chain-kind/address mismatch, invalid amount, over-bound " | ||
| "metadata, or unlinked / malformed creator key)\n"); | ||
| // Do not persist attacker-sized address bytes in the sysio-billed | ||
| // cancellation tombstone. The outpost refunds by the reserve triple, | ||
| // so a malformed creator address is unnecessary for that handshake. | ||
| if (invalid_creator_address) creator.address.clear(); | ||
| const auto now = current_time_ms(); | ||
| tbl.emplace(ram_payer, pk, reserve_row{ | ||
| .chain_code = chain_code, | ||
|
|
@@ -640,7 +670,9 @@ void reserve::oncnclrsv(sysio::slug_name chain_code, | |
| opp::types::ChainKind creator_chain_kind, | ||
| std::vector<char> creator_chain_addr) { | ||
| require_auth(MSGCH_ACCOUNT); | ||
| if (!registered_chain_or_skip(chain_code, "oncnclrsv")) return; | ||
| const auto expected_chain_kind = | ||
| registered_chain_kind_or_skip(chain_code, "oncnclrsv"); | ||
| if (!expected_chain_kind.has_value()) return; | ||
|
|
||
| reserves_t tbl(get_self()); | ||
| auto pk = make_key(chain_code, token_code, reserve_code); | ||
|
|
@@ -656,7 +688,8 @@ void reserve::oncnclrsv(sysio::slug_name chain_code, | |
| } | ||
|
|
||
| const bool addr_matches = | ||
| it->creator_addr.kind == creator_chain_kind && | ||
| creator_chain_kind == *expected_chain_kind && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correcting myself: I called this cosmetic, but No live risk — ETH |
||
| it->creator_addr.kind == *expected_chain_kind && | ||
| it->creator_addr.address == creator_chain_addr; | ||
| if (!addr_matches) { | ||
| sysio::print("oncnclrsv: creator_addr mismatch; silently skipping\n"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,14 @@ std::vector<char> em_pubkey_bytes(const fc::crypto::public_key& pk) { | |
| return std::vector<char>(compressed.begin(), compressed.end()); | ||
| } | ||
|
|
||
| /// Extract the raw 32-byte ed25519 pubkey carried by an SVM ChainAddress. | ||
| std::vector<char> ed_pubkey_bytes(const fc::crypto::public_key& pk) { | ||
| const auto& shim = pk.get<fc::crypto::ed::public_key_shim>(); | ||
| const auto raw = shim.serialize(); | ||
| return std::vector<char>(reinterpret_cast<const char*>(raw.data()), | ||
| reinterpret_cast<const char*>(raw.data()) + raw.size()); | ||
| } | ||
|
|
||
| } // anonymous namespace | ||
|
|
||
| /// v6 data-model: reserves are keyed by the triple `(chain_code, token_code, | ||
|
|
@@ -336,8 +344,8 @@ class sysio_reserve_tester : public tester { | |
| /// Seed an authex link for `pub` on `chain_kind` via the depot-only | ||
| /// `recordlink` (signed by sysio.authex itself). After this, a creator | ||
| /// presenting the matching raw pubkey reads as linked in oncrtreserve. | ||
| action_result recordlink_em(name account, ChainKind chain_kind, | ||
| const fc::crypto::public_key& pub) { | ||
| action_result recordlink(name account, ChainKind chain_kind, | ||
| const fc::crypto::public_key& pub) { | ||
| return push_to(AUTHEX_ACCOUNT, authex_abi_ser, AUTHEX_ACCOUNT, "recordlink"_n, mvo() | ||
| ("account", account) | ||
| ("chain_kind", chain_kind) | ||
|
|
@@ -547,6 +555,79 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_unlinked_creator_is_cancelled, sysio_reserv | |
| BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| // WNS-28: `chain_code` determines the authoritative ChainKind. Before the fix, | ||
| // a payload could name ETH while supplying SVM plus an authex-linked ED key; | ||
| // oncrtreserve trusted the payload kind and created a PENDING row that | ||
| // matchreserve could never match using ETH's registered EVM kind. The handler | ||
| // must reject through its non-throwing cancel/refund path instead. | ||
| BOOST_FIXTURE_TEST_CASE(oncrtreserve_creator_chain_kind_mismatch_is_cancelled, | ||
| sysio_reserve_tester) { try { | ||
| deploy_authex(); | ||
|
huangminghuang marked this conversation as resolved.
|
||
| deploy_msgch(); | ||
|
|
||
| auto creator_pub = fc::crypto::private_key::generate( | ||
| fc::crypto::private_key::key_type::ed).get_public_key(); | ||
| BOOST_REQUIRE_EQUAL(success(), | ||
| recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, creator_pub)); | ||
| const auto creator_key = ed_pubkey_bytes(creator_pub); | ||
|
|
||
| BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() | ||
| ("chain_code", codename_mvo("ETH")) | ||
| ("token_code", codename_mvo("ETH")) | ||
| ("reserve_code", codename_mvo("USERRES")) | ||
| ("name", "mismatched creator") | ||
| ("description", "") | ||
| ("external_token_amount", 1000) | ||
| ("requested_wire_amount", 1000) | ||
| ("source_token_precision", 9u) | ||
| ("connector_weight_bps", 5000) | ||
| ("creator_chain_kind", ChainKind::CHAIN_KIND_SVM) | ||
| ("creator_chain_addr", creator_key) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test no longer isolates the guard it was written for. Deleting To isolate it: keep a valid 20-byte |
||
| ("is_private", false) | ||
| ("creator_pub_key", creator_key))); | ||
|
|
||
| auto r = find_reserve("ETH", "ETH", "USERRES"); | ||
| BOOST_REQUIRE(!r.is_null()); | ||
| BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); | ||
| BOOST_REQUIRE(r["creator_pub_key"].as_string().empty()); | ||
|
|
||
| // The mismatch must take the refund path, not merely write the tombstone. | ||
| // `queueout` starts attestation ids at 1 (id 0 is its sequence singleton). | ||
| auto queued = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, 1); | ||
| BOOST_REQUIRE(!queued.empty()); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_creator_address_is_cancelled, | ||
| sysio_reserve_tester) { try { | ||
| deploy_authex(); | ||
|
|
||
| auto creator_pub = fc::crypto::private_key::generate( | ||
| fc::crypto::private_key::key_type::em).get_public_key(); | ||
| BOOST_REQUIRE_EQUAL(success(), | ||
| recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
| const auto creator_key = em_pubkey_bytes(creator_pub); | ||
|
|
||
| BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() | ||
| ("chain_code", codename_mvo("ETH")) | ||
| ("token_code", codename_mvo("ETH")) | ||
| ("reserve_code", codename_mvo("BADADDR")) | ||
| ("name", "malformed creator address") | ||
| ("description", "") | ||
| ("external_token_amount", 1000) | ||
| ("requested_wire_amount", 1000) | ||
| ("source_token_precision", 9u) | ||
| ("connector_weight_bps", 5000) | ||
| ("creator_chain_kind", ChainKind::CHAIN_KIND_EVM) | ||
| ("creator_chain_addr", std::vector<char>(32, '\x01')) | ||
| ("is_private", false) | ||
| ("creator_pub_key", creator_key))); | ||
|
|
||
| auto r = find_reserve("ETH", "ETH", "BADADDR"); | ||
| BOOST_REQUIRE(!r.is_null()); | ||
| BOOST_REQUIRE_EQUAL("RESERVE_STATUS_CANCELLED", r["status"].as_string()); | ||
| BOOST_REQUIRE(r["creator_addr"]["address"].as_string().empty()); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| // A re-relay of the same unlinked create must be idempotent — it must NOT | ||
| // re-insert the row or queue a second RESERVE_CREATE_CANCELLED refund. The | ||
| // CANCELLED marker stays exactly as first written (the outpost refunds per | ||
|
|
@@ -620,7 +701,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_cancelled_is_reclaimable_by_linked_creator, | |
| auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); | ||
| auto creator_pub = creator_priv.get_public_key(); | ||
| BOOST_REQUIRE_EQUAL(success(), | ||
| recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
| recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
|
|
||
| BOOST_REQUIRE_EQUAL(success(), push_action(MSGCH_ACCOUNT, "oncrtreserve"_n, mvo() | ||
| ("chain_code", codename_mvo("ETH")) | ||
|
|
@@ -663,7 +744,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_invalid_amount_is_cancelled, sysio_reserve_ | |
| auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); | ||
| auto creator_pub = creator_priv.get_public_key(); | ||
| BOOST_REQUIRE_EQUAL(success(), | ||
| recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
| recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
|
|
||
| // Linked creator, but external_token_amount == 0 (the clamp result for an | ||
| // invalid inbound amount). The link is valid, so the amount alone forces the | ||
|
|
@@ -710,7 +791,7 @@ BOOST_FIXTURE_TEST_CASE(oncrtreserve_oversized_metadata_is_cancelled, sysio_rese | |
| auto creator_priv = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em); | ||
| auto creator_pub = creator_priv.get_public_key(); | ||
| BOOST_REQUIRE_EQUAL(success(), | ||
| recordlink_em("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
| recordlink("alice"_n, ChainKind::CHAIN_KIND_EVM, creator_pub)); | ||
|
|
||
| auto create_with_metadata = [&](std::string_view reserve_code, | ||
| const std::string& name, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the outposts are ours and honest, this is the finding most likely to actually bite. Two
ifs and areturn falsemean a futureChainKind— a new outpost doing everything correctly — silently routes every create on that chain into cancel+refund, with no compile-time signal and no error distinguishable from a malformed payload.An exhaustive
switchoverChainKindwould let-Wswitchflag the new enumerator at build time, which is the posture the enums-are-first-class rule asks for. Same fail-closed behavior, just not silently.