Skip to content

WIRE-360: Remove unsafe auth notification path - #614

Merged
huangminghuang merged 6 commits into
masterfrom
fix/wire-360-wns-22
Sep 21, 2026
Merged

huangminghuang merged 6 commits into
masterfrom
fix/wire-360-wns-22

Conversation

@huangminghuang

@huangminghuang huangminghuang commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Remove the obsolete auth.msg::onlinkauth notification handler and its auth.ext permission constant, eliminating the path that let a claimed auth.msg account modify sysio permissions.
  • Reject node-owner account names beginning with the reserved sysio. prefix through the shared ROA name validator, covering both account creation and registration audit paths.
  • Add dispatch and ROA regressions proving attacker-controlled notifications are inert and reserved system names are rejected without account creation or RAM movement.
  • Merge current master while retaining both the WIRE-360 reserved-name constant and the new account-name generator constants, then rebuild and synchronize sysio.roa.wasm and sysio.system.wasm from the combined source.
  • Remove the stale sig-em tutorial that depended on the deleted authorization path.

Why

The previous notification path could be reached by a tier-2/3 node owner that claimed auth.msg; on the original base it could replace sysio@active. Separately, node owners could claim sysio.* names and receive system-account resource treatment. This PR removes the unused privileged handler entirely and closes the reserved-name gap. Current master also changed ROA account-name generation and the system contract, so the final generated artifacts must come from the merged source rather than either side of the conflict. The affected functionality is pre-launch, so no migration is required.

Validation

  • Release/Ninja configure with BUILD_SYSTEM_CONTRACTS=ON, BUILD_TEST_CONTRACTS=ON, and ENABLE_TESTS=ON
  • Full build and generated contract artifact parity
  • contracts_unit_test: all 783 cases passed under sys-vm-jit
  • Monitored local flow-node-owner-nft: passed against the exact PR head using the current merged wire-tools producer-admission support
  • Full remote platform run: all 15 normal flows passed against wire-sysio 86d28c461588fd940b461fa0e4ec97b3cdc7c9c4 and the exact locally proven cross-repository revisions

Change-Id: I2628c5e916d67b31cdf9c1d1f8259e654e4ff5a1
@huangminghuang
huangminghuang requested review from a team and heifner September 14, 2026 18:09
@heifner

heifner commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

I reproduced this in a test, and on master it is worse than the audit rating: a tier-2/3 node owner can take over sysio@active through onlinkauth. Narrowing the handler to auth.ext stops that, but whoever controls auth.msg still gets a key on sysio's permission tree, and nothing legitimate calls onlinkauth anymore. I'd like to delete the handler instead, and close the sysio. account-name gap the investigation turned up in this same PR.

What the handler can reach

How it is reachable on master

  1. A holder of a tier-2/3 WireNodes unit commits it with the Wire account name auth.msg. BAR._isValidWireAccountName, msgch::parse_owner_name and roa::valid_name_for_tier check only charset and length, so the depot creates auth.msg with the holder's key and registers it as CONFIRMED.
  2. The holder issues itself an ROA policy from its tier budget and sets code on auth.msg that forwards every action to sysio, the same stand-in this PR's test uses.
  3. auth.msg::onlinkauth{sysio, active, K} replaces sysio@active with K. The previous authority no longer satisfies sysio@active, and K can push setpriv. sysio@owner is safe because an authority cannot be its own parent.

With this PR's wasm, owner and active are refused, but onlinkauth{sysio, auth.ext, K} still installs K as sysio@auth.ext, and K can then add child permissions under it, each billed to sysio's RAM.

The same claim path accepts sysio.-prefixed names. A tier-2 claim of sysio.pwn is CONFIRMED, and a RAM-only ROA policy then gives it unlimited CPU/NET, because add_system_resources treats sysio.* owners as system accounts. The chain's only guard is the privileged-creator rule, and sysio.roa is privileged.

Requested changes

  1. Delete system_contract::onlinkauth and the auth_ext_permission constant, rebuild sysio.system.wasm, and drop sysio.system_authmsg_tests.cpp. Also remove the stale tutorials/sig-em-tutorial and its .gitignore entry.
  2. Refuse sysio. names to node owners in roa::valid_name_for_tier, so newnameduser creates nothing and nodeownreg records NAME_INVALID. Match on the leading sysio. rather than name::prefix(), which returns everything before the last dot and would miss sysio.a.b. Rebuild sysio.roa.wasm. (BAR.commitNode could also refuse these up front so the unit is never escrowed, but tier-length violations already take the depot-reject plus releaseNode path, so that part is optional.)

With the current CDT, every other contract rebuilds byte-identical to this branch's committed wasm, so only sysio.system.wasm and sysio.roa.wasm change. The rebuilt sysio.roa.wasm does not disturb the snapshot, deep-mind or block-compatibility reference data.

Regression tests

The patch below applies to this branch with git apply. It contains these tests and the changes above (without the rebuilt wasm). Each new test fails on this branch and passes with the changes.

  • sysio_dispatch_tests/claimed_auth_msg_notification_changes_no_permission: claims auth.msg through the depot, funds it, deploys the forwarder, then sends onlinkauth naming owner, active, auth.ext and another permission, for both sysio and a user account. Every notification must commit as a plain notification, and neither account's permissions may change.
  • sysio_dispatch_tests/node_owner_claim_rejects_reserved_system_name: a depot claim of sysio.pwn is REJECTED with NAME_INVALID and creates no account.
  • sysio_roa_tests/nodeownreg_rejects_reserved_system_names: newnameduser and nodeownreg for sysio.pwn (tier 2) and sysio.a.b (tier 3).

contract_test_support.hpp gains shared mirrors of the nodeownerreg status and reason values, so the dispatch and roa tests no longer define their own.

Patch (git apply on this branch)
diff --git a/.gitignore b/.gitignore
index 4d7fe2b3dd..dbd166ae7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,7 +73,6 @@ scripts/tn_init.sh
 
 tests/plugin_test
 unittests/unit_test
-tutorials/sig-em-tutorial/contracts
 
 doxygen
 wire.doxygen
diff --git a/contracts/sysio.roa/sysio.roa.cpp b/contracts/sysio.roa/sysio.roa.cpp
index 3f996c7766..c480fa308b 100644
--- a/contracts/sysio.roa/sysio.roa.cpp
+++ b/contracts/sysio.roa/sysio.roa.cpp
@@ -6,6 +6,8 @@
 #include <sysio.opp.common/safe_ops.hpp>   // add_sat_u64 / add_sat_i64 -- never-throw saturating accumulators
 #include <sysio/permission.hpp>   // get_permission -- read an account's active authority in nodeownreg
 
+#include <string_view>
+
 namespace sysio {
 
     namespace {
@@ -13,6 +15,10 @@ namespace sysio {
         // literals -- a contract rename is one change here, not scattered across call sites).
         constexpr name AUTHEX_ACCOUNT    = "sysio.authex"_n;
         constexpr name AUTHEX_RECORDLINK = "recordlink"_n;
+
+        // Names under this prefix belong to system accounts. The chain refuses them only to non-privileged
+        // creators, and sysio.roa is privileged, so node-owner claims must refuse them here.
+        constexpr std::string_view RESERVED_SYSTEM_NAME_PREFIX = "sysio.";
     } // anonymous namespace
 
     static bool is_sysio_account(const name& account) {
@@ -795,6 +801,7 @@ namespace sysio {
     }
 
     bool roa::valid_name_for_tier(const name& account, uint8_t tier) {
+        if (account.to_string().rfind(RESERVED_SYSTEM_NAME_PREFIX, 0) == 0) return false;
         const size_t len = account.length();
         // Tier-1 owners take a short 2-6 char prefix (sub-accounts become <prefix>.<random>);
         // tier 2/3 take a 1-12 char vanity name.
diff --git a/contracts/sysio.roa/sysio.roa.hpp b/contracts/sysio.roa/sysio.roa.hpp
index 71cf972ceb..3802e2ec4d 100644
--- a/contracts/sysio.roa/sysio.roa.hpp
+++ b/contracts/sysio.roa/sysio.roa.hpp
@@ -371,7 +371,7 @@ namespace sysio {
             // to migrate.)
             enum reject_reason : uint8_t {
                 NONE                 = 0,  // not rejected
-                NAME_INVALID         = 1,  // chosen account name violates the tier's length rule
+                NAME_INVALID         = 1,  // chosen name violates the tier's length rule or is a reserved sysio. name
                 OWNER_NOT_ACCOUNT    = 2,  // account does not exist (creation did not occur)
                 ACCOUNT_KEY_MISMATCH = 3,  // existing account's active authority != the single claimed wire key
                 DUPLICATE            = 4,  // owner is already a registered node owner
@@ -486,14 +486,15 @@ namespace sysio {
                                 uint8_t network_gen);
 
             /**
-             * @brief Whether `account`'s name satisfies the node-owner name-length rule for `tier`.
+             * @brief Whether `account`'s name satisfies the node-owner naming rules for `tier`.
              *        Tier-1 owners take a short 2-6 char prefix (sub-accounts become <prefix>.<random>);
-             *        tier 2/3 take a 1-12 char vanity name. Shared by newnameduser (gates creation) and
-             *        nodeownreg (records NAME_INVALID) so the rule lives in one place.
+             *        tier 2/3 take a 1-12 char vanity name, and no tier may take a name under the reserved
+             *        `sysio.` prefix. Shared by newnameduser (gates creation) and nodeownreg (records
+             *        NAME_INVALID) so the rule lives in one place.
              *
              * @param account The chosen account name.
              * @param tier    Node-owner tier (must already be validated to 1-3).
-             * @return true if the name length is valid for the tier.
+             * @return true if the name is valid for the tier.
              */
             static bool valid_name_for_tier(const name& account, uint8_t tier);
 
diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp
index e8da39f8a7..25f8dd5811 100644
--- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp
+++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp
@@ -341,7 +341,6 @@ namespace sysiosystem {
 
       public:
          static constexpr sysio::name active_permission{"active"_n};
-         static constexpr sysio::name auth_ext_permission{"auth.ext"_n};
          static constexpr sysio::name token_account{"sysio.token"_n};
          static constexpr sysio::name null_account{"sysio.null"_n};
 
@@ -619,20 +618,6 @@ namespace sysiosystem {
          [[sysio::action]]
          void limitauthchg( const name& account, const std::vector<name>& allow_perms, const std::vector<name>& disallow_perms );
 
-         /**
-          * Install or rotate the reserved auth.ext child permission from an auth.msg notification.
-          *
-          * The notification payload is untrusted input. Only the protocol-reserved auth.ext permission
-          * may be changed through this privileged path; owner, active, and arbitrary permission names
-          * are rejected.
-          *
-          * @param user - account whose auth.ext permission is installed or rotated.
-          * @param permission - must be exactly auth.ext.
-          * @param pub_key - sole key for the auth.ext authority.
-          */
-         [[sysio::on_notify("auth.msg::onlinkauth")]]
-         void onlinkauth(const name &user, const name &permission, const sysio::public_key &pub_key);
-
          /**
           * Rescore a producer whose collateral standing just changed on sysio.opreg.
           *
diff --git a/contracts/sysio.system/src/sysio.system.cpp b/contracts/sysio.system/src/sysio.system.cpp
index d9fdbcfeba..440219ba29 100644
--- a/contracts/sysio.system/src/sysio.system.cpp
+++ b/contracts/sysio.system/src/sysio.system.cpp
@@ -268,21 +268,4 @@ namespace sysiosystem {
 
       check(core == symbol("SYS", 4), "core symbol must be SYS.");
    }
-
-   // ** ON NOTIFY OF AUTH.MSG MODIFICATION **
-   void system_contract::onlinkauth( const name& account_name,
-                                     const name& permission,
-                                     const sysio::public_key& pub_key ) {
-      check( permission == auth_ext_permission, "onlinkauth may only update auth.ext" );
-
-      // Convert pub_key to authority object
-      authority auth;
-      auth.threshold = 1;
-
-      auth.keys.push_back({pub_key, 1});
-
-      // Update auth with special permission.
-      updateauth_action update_auth{ get_self(), { {get_self(), active_permission} } };
-      update_auth.send(account_name, auth_ext_permission, active_permission, auth, name{});
-   }
 } /// sysio.system
diff --git a/contracts/tests/contract_test_support.hpp b/contracts/tests/contract_test_support.hpp
index 05637c0cda..7f89f085cb 100644
--- a/contracts/tests/contract_test_support.hpp
+++ b/contracts/tests/contract_test_support.hpp
@@ -117,4 +117,15 @@ inline fc::mutable_variant_object svm_outpost_mvo(std::string_view program_id) {
       ("source_deposit_addr",    std::string{});
 }
 
+/// Mirrors of sysio.roa's `nodeownerreg` audit values (`reg_status` / `reject_reason` in sysio.roa.hpp).
+namespace nodeownerreg {
+inline constexpr uint64_t status_confirmed            = 0;
+inline constexpr uint64_t status_rejected             = 1;
+inline constexpr uint64_t reason_name_invalid         = 1;
+inline constexpr uint64_t reason_owner_not_account    = 2;
+inline constexpr uint64_t reason_account_key_mismatch = 3;
+inline constexpr uint64_t reason_duplicate            = 4;
+inline constexpr uint64_t reason_link_key_mismatch    = 5;
+} // namespace nodeownerreg
+
 } // namespace sysio_system::test_support
diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp
index 1b6d72e458..b4ecb5b45b 100644
--- a/contracts/tests/sysio.dispatch_tests.cpp
+++ b/contracts/tests/sysio.dispatch_tests.cpp
@@ -14,6 +14,7 @@
 #include <sysio/chain/authorization_manager.hpp>
 #include <sysio/chain/resource_limits.hpp>
 #include <sysio/chain/permission_object.hpp>
+#include <sysio/chain/wast_to_wasm.hpp>
 #include <sysio/chain/kv_table_objects.hpp>   // kv_index / by_code_key for reading sysio.roa kv tables
 #include <sysio/opp/opp.hpp>
 #include <sysio/opp/opp.pb.h>
@@ -38,9 +39,12 @@
 #include <algorithm>
 #include <array>
 #include <iterator>
+#include <map>
+#include <sstream>
 
 #include "contracts.hpp"
 #include "contract_test_support.hpp"
+#include "test_symbol.hpp"
 // Canonical-encoding + header-derivation oracle: inbound envelopes must carry
 // spec-derived semantic headers or apply_consensus drops them before dispatch.
 #include "opp_envelope_oracle.hpp"
@@ -330,6 +334,14 @@ std::string encode_swap_request(
 
 } // anonymous namespace
 
+/// Wire layout of an `auth.msg::onlinkauth` action, the payload sysio.system once acted on.
+struct onlinkauth_notification {
+   name            user;
+   name            permission;
+   public_key_type pub_key;
+};
+FC_REFLECT(onlinkauth_notification, (user)(permission)(pub_key))
+
 class sysio_dispatch_tester : public tester {
 public:
    static constexpr auto MSGCH_ACCOUNT  = "sysio.msgch"_n;
@@ -1951,7 +1963,7 @@ BOOST_FIXTURE_TEST_CASE(dispatch_routes_node_owner_reg_to_roa, sysio_dispatch_te
    BOOST_REQUIRE_EQUAL(reg["tier"].as<uint32_t>(), 2u);
    auto audit = get_nodeownerreg(CLAIM_ACCOUNT);
    BOOST_REQUIRE(!audit.is_null());
-   BOOST_REQUIRE_EQUAL(audit["status"].as<uint64_t>(), 0u);  // CONFIRMED
+   BOOST_REQUIRE_EQUAL(audit["status"].as<uint64_t>(), sysio_system::test_support::nodeownerreg::status_confirmed);
 } FC_LOG_AND_RETHROW() }
 
 // WSA-005: node-owner registration is bound to the EXACT Ethereum source outpost (NODE_OWNER_SRC_CHAIN
@@ -2015,6 +2027,161 @@ BOOST_FIXTURE_TEST_CASE(node_owner_reg_from_non_evm_outpost_is_dropped, sysio_di
    BOOST_REQUIRE(get_nodeownerreg(CLAIM_ACCOUNT).is_null());
 } FC_LOG_AND_RETHROW() }
 
+/// Node-owner claims on a chain running sysio.system. A tier-2/3 claim lets the NFT holder pick any valid 1-12 char
+/// name and control the account created for it; these cases pin what that control must not reach.
+class node_owner_claim_tester : public sysio_dispatch_tester {
+public:
+   static constexpr auto     NODE_OWNER_SOURCE_CHAIN = "ETHEREUM";
+   static constexpr uint32_t CLAIM_TIER              = 2;
+   static constexpr auto     AUTH_MSG_ACCOUNT        = "auth.msg"_n;
+   static constexpr auto     ONLINKAUTH_ACTION       = "onlinkauth"_n;
+   static constexpr auto     AUTH_EXT_PERMISSION     = "auth.ext"_n;
+   static constexpr auto     OTHER_PERMISSION        = "session"_n;
+   static constexpr auto     RESERVED_SYSTEM_NAME    = "sysio.pwn"_n;
+   static constexpr auto     PAYER_ACCOUNT           = "payer"_n;
+   /// Never created; only names the key a notification offers.
+   static constexpr auto     REPLACEMENT_KEY_NAME    = "replacement"_n;
+   static constexpr auto     SYSTEM_INIT_ACTION      = "init"_n;
+   static constexpr auto     ADDPOLICY_ACTION        = "addpolicy"_n;
+   static constexpr auto     SELF_POLICY_WEIGHT      = "0.1000 SYS";
+
+   /// Parent and authority of each of an account's permissions, keyed by permission name.
+   using permission_set = std::map<name, std::pair<name, authority>>;
+
+   /// Deploy and initialize sysio.system; the base dispatch fixture runs without it.
+   void deploy_system_contract() {
+      set_code(config::system_account_name, contracts::system_wasm());
+      set_abi(config::system_account_name, contracts::system_abi().data());
+      produce_block();
+      base_tester::push_action(config::system_account_name, SYSTEM_INIT_ACTION, config::system_account_name,
+                               mvo()("version", 0)("core", CORE_SYM_STR));
+      produce_block();
+   }
+
+   /// Deliver a NodeOwnerRegistration for `account` through the Ethereum outpost, as BAR.commitNode emits it.
+   void claim_node_owner(name account, const public_key_type& wire_key) {
+      const auto eth_key  = fc::crypto::private_key::generate(fc::crypto::private_key::key_type::em).get_public_key();
+      const auto payload  = encode_node_owner_registration(account.to_string(), CLAIM_TIER,
+                                                           sysio::opp::types::WIRE_KEY_TYPE_K1,
+                                                           k1_pubkey_bytes(wire_key), em_pubkey_bytes(eth_key));
+      const auto envelope = encode_envelope_with_one_attestation(
+         current_epoch(), sysio::opp::types::ATTESTATION_TYPE_NODE_OWNER_REG, payload);
+      BOOST_REQUIRE_EQUAL(success(), deliver(fc::slug_name{NODE_OWNER_SOURCE_CHAIN}.value, envelope));
+      produce_blocks(2);
+   }
+
+   /// The claimant issues a policy to itself from its tier budget; PAYER_ACCOUNT pays for the transaction.
+   void issue_own_policy(name owner) {
+      signed_transaction trx;
+      trx.actions.emplace_back(get_action(config::roa_account_name, ADDPOLICY_ACTION,
+         vector<permission_level>{{PAYER_ACCOUNT, config::sysio_payer_name}, {PAYER_ACCOUNT, config::active_name},
+                                  {owner, config::active_name}},
+         mvo()("owner", owner)("issuer", owner)("net_weight", SELF_POLICY_WEIGHT)("cpu_weight", SELF_POLICY_WEIGHT)
+              ("ram_weight", SELF_POLICY_WEIGHT)("time_block", 0)("network_gen", ROA_NETWORK_GEN)));
+      set_transaction_headers(trx);
+      trx.sign(get_private_key(PAYER_ACCOUNT, "active"), control->get_chain_id());
+      trx.sign(get_private_key(owner, "active"), control->get_chain_id());
+      push_transaction(trx);
+      produce_block();
+   }
+
+   /// Code a claimant can deploy on its account: it forwards every action it receives to sysio as a notification.
+   static std::vector<uint8_t> notify_system_account_wasm() {
+      std::ostringstream wast;
+      wast << R"((module
+         (import "env" "require_recipient" (func $require_recipient (param i64)))
+         (func (export "apply") (param i64 i64 i64)
+            (call $require_recipient (i64.const 0x)"
+           << std::hex << config::system_account_name.to_uint64_t() << R"())
+         )
+      ))";
+      return wast_to_wasm(wast.str());
+   }
+
+   /// Push auth.msg::onlinkauth as auth.msg; true when the transaction committed and the notification reached sysio.
+   bool notify_onlinkauth(name user, name permission, const public_key_type& key) {
+      signed_transaction trx;
+      trx.actions.emplace_back(vector<permission_level>{{AUTH_MSG_ACCOUNT, config::active_name}}, AUTH_MSG_ACCOUNT,
+                               ONLINKAUTH_ACTION, fc::raw::pack(onlinkauth_notification{user, permission, key}));
+      set_transaction_headers(trx);
+      trx.sign(get_private_key(AUTH_MSG_ACCOUNT, "active"), control->get_chain_id());
+      bool delivered = false;
+      try {
+         const auto trace = push_transaction(trx);
+         delivered = std::ranges::any_of(trace->action_traces, [](const action_trace& at) {
+            return at.receiver == config::system_account_name && at.act.name == ONLINKAUTH_ACTION;
+         });
+      } catch (const fc::exception&) {
+         delivered = false;
+      }
+      produce_block();
+      return delivered;
+   }
+
+   /// Native newaccount creates only the account_object; metadata appears once code, abi or privilege is set.
+   bool account_exists(name account) const {
+      return control->db().find<account_object, by_name>(account) != nullptr;
+   }
+
+   /// Every permission of `account`, for before/after comparison.
+   permission_set permissions_of(name account) const {
+      permission_set out;
+      const auto& db  = control->db();
+      const auto& idx = db.get_index<permission_index, by_owner>();
+      for (auto it = idx.lower_bound(boost::make_tuple(account)); it != idx.end() && it->owner == account; ++it) {
+         const name parent = it->parent._id == 0 ? name{} : db.get<permission_object, by_id>(it->parent).name;
+         out.emplace(it->name, std::make_pair(parent, it->auth.to_authority()));
+      }
+      return out;
+   }
+};
+
+// A tier-2/3 claim can take the name auth.msg and deploy code there, so an auth.msg::onlinkauth notification is
+// attacker-controlled. It must reach sysio as an ordinary notification and change nothing: not sysio's owner, active,
+// auth.ext or any other permission, and not a user's.
+BOOST_FIXTURE_TEST_CASE(claimed_auth_msg_notification_changes_no_permission, node_owner_claim_tester) { try {
+   bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN);
+   create_account(PAYER_ACCOUNT);
+   deploy_system_contract();
+
+   claim_node_owner(AUTH_MSG_ACCOUNT, get_public_key(AUTH_MSG_ACCOUNT, "active"));
+   const auto audit = get_nodeownerreg(AUTH_MSG_ACCOUNT);
+   BOOST_REQUIRE(!audit.is_null());
+   BOOST_REQUIRE_EQUAL(audit["status"].as<uint64_t>(), sysio_system::test_support::nodeownerreg::status_confirmed);
+   issue_own_policy(AUTH_MSG_ACCOUNT);
+   set_code(AUTH_MSG_ACCOUNT, notify_system_account_wasm());
+   produce_block();
+
+   const auto replacement_key = get_public_key(REPLACEMENT_KEY_NAME, "active");
+   const auto sysio_before    = permissions_of(config::system_account_name);
+   const auto user_before     = permissions_of(CLAIM_ACCOUNT);
+   for (const auto target : {config::system_account_name, CLAIM_ACCOUNT}) {
+      for (const auto permission : {config::owner_name, config::active_name, AUTH_EXT_PERMISSION, OTHER_PERMISSION}) {
+         BOOST_CHECK_MESSAGE(notify_onlinkauth(target, permission, replacement_key),
+                             "onlinkauth for " << target.to_string() << "@" << permission.to_string()
+                                               << " did not commit as a plain notification");
+      }
+   }
+   BOOST_CHECK_MESSAGE(permissions_of(config::system_account_name) == sysio_before, "sysio permissions changed");
+   BOOST_CHECK_MESSAGE(permissions_of(CLAIM_ACCOUNT) == user_before, "claimacct permissions changed");
+} FC_LOG_AND_RETHROW() }
+
+// No node owner may claim a name under the reserved sysio. prefix: the depot's claim is rejected as NAME_INVALID and
+// no account is created.
+BOOST_FIXTURE_TEST_CASE(node_owner_claim_rejects_reserved_system_name, node_owner_claim_tester) { try {
+   bootstrap_for_dispatch(NODE_OWNER_SOURCE_CHAIN);
+   deploy_system_contract();
+
+   claim_node_owner(RESERVED_SYSTEM_NAME, get_public_key(RESERVED_SYSTEM_NAME, "active"));
+
+   const auto audit = get_nodeownerreg(RESERVED_SYSTEM_NAME);
+   BOOST_REQUIRE(!audit.is_null());
+   BOOST_CHECK_EQUAL(audit["status"].as<uint64_t>(), sysio_system::test_support::nodeownerreg::status_rejected);
+   BOOST_CHECK_EQUAL(audit["reason"].as<uint64_t>(), sysio_system::test_support::nodeownerreg::reason_name_invalid);
+   BOOST_CHECK(get_nodeowner(RESERVED_SYSTEM_NAME).is_null());
+   BOOST_CHECK(!account_exists(RESERVED_SYSTEM_NAME));
+} FC_LOG_AND_RETHROW() }
+
 /// Regression: a non-advancing advance() must not permanently strand the epoch.
 ///
 /// When every active outpost has reached consensus and the wall clock has passed, chkcons triggers
diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp
index 7eb4c8dc20..8b9cc01d42 100644
--- a/contracts/tests/sysio.roa_tests.cpp
+++ b/contracts/tests/sysio.roa_tests.cpp
@@ -6,6 +6,7 @@
 #include <sysio/chain/permission_object.hpp>
 #include <sysio/chain/subjective_billing.hpp>
 #include "sysio.system_tester.hpp"
+#include "contract_test_support.hpp"
 #include <contracts.hpp>
 #include <sysio/opp/opp.hpp>
 #include <fc/variant_object.hpp>
@@ -1940,6 +1941,8 @@ BOOST_FIXTURE_TEST_CASE( newnameduser_tier_name_rules, sysio_roa_tester ) try {
 // sysio.authex.active <- sysio.roa@sysio.code delegation that authorizes the inline recordlink.
 // ---------------------------------------------------------------------------
 
+namespace nodeownerreg_audit = sysio_system::test_support::nodeownerreg;
+
 class sysio_roa_nodeownreg_tester : public sysio_roa_tester {
 public:
    static constexpr auto AUTHEX = "sysio.authex"_n;
@@ -2004,10 +2007,14 @@ public:
       return fc::crypto::private_key::generate(fc::crypto::private_key::key_type::k1).get_public_key();
    }
 
-   // nodeownerreg reg_status + reject_reason values (mirror sysio.roa.hpp).
-   static constexpr uint64_t CONFIRMED = 0, REJECTED = 1;
-   static constexpr uint64_t R_NAME_INVALID = 1, R_OWNER_NOT_ACCOUNT = 2,
-                             R_ACCOUNT_KEY_MISMATCH = 3, R_DUPLICATE = 4, R_LINK_KEY_MISMATCH = 5;
+   // nodeownerreg reg_status + reject_reason values, shared with the depot dispatch tests.
+   static constexpr uint64_t CONFIRMED              = nodeownerreg_audit::status_confirmed;
+   static constexpr uint64_t REJECTED               = nodeownerreg_audit::status_rejected;
+   static constexpr uint64_t R_NAME_INVALID         = nodeownerreg_audit::reason_name_invalid;
+   static constexpr uint64_t R_OWNER_NOT_ACCOUNT    = nodeownerreg_audit::reason_owner_not_account;
+   static constexpr uint64_t R_ACCOUNT_KEY_MISMATCH = nodeownerreg_audit::reason_account_key_mismatch;
+   static constexpr uint64_t R_DUPLICATE            = nodeownerreg_audit::reason_duplicate;
+   static constexpr uint64_t R_LINK_KEY_MISMATCH    = nodeownerreg_audit::reason_link_key_mismatch;
 
    abi_serializer authex_abi_ser;
 };
@@ -2309,6 +2316,33 @@ BOOST_FIXTURE_TEST_CASE( nodeownreg_name_invalid, sysio_roa_nodeownreg_tester )
    BOOST_REQUIRE_EQUAL(audit["reason"].as<uint64_t>(), R_NAME_INVALID);
 } FC_LOG_AND_RETHROW()
 
+// No tier may claim a name under the reserved sysio. prefix, however deep: newnameduser creates nothing (sysio's RAM
+// pool is untouched) and nodeownreg records REJECTED/NAME_INVALID.
+BOOST_FIXTURE_TEST_CASE( nodeownreg_rejects_reserved_system_names, sysio_roa_nodeownreg_tester ) try {
+   constexpr auto    tier2_name  = "sysio.pwn"_n;
+   constexpr auto    nested_name = "sysio.a.b"_n;   // prefix() is "sysio.a": the rule must match the leading segment
+   constexpr uint8_t tier2 = 2, tier3 = 3;
+   auto& rlm = control->get_resource_limits_manager();
+   const auto wire_pub = gen_k1_key();
+
+   for (const auto& [owner, tier] : {std::pair{tier2_name, tier2}, std::pair{nested_name, tier3}}) {
+      int64_t net = 0, cpu = 0, pool_before = 0, pool_after = 0;
+      rlm.get_account_limits(config::system_account_name, pool_before, net, cpu);
+      BOOST_REQUIRE_EQUAL(success(), newnameduser(owner, wire_pub, tier));
+      produce_blocks();
+      rlm.get_account_limits(config::system_account_name, pool_after, net, cpu);
+      BOOST_CHECK_EQUAL(pool_before, pool_after);
+
+      BOOST_REQUIRE_EQUAL(success(), nodeownreg(owner, tier, gen_em_key(), wire_pub));
+      produce_blocks();
+      BOOST_CHECK(get_nodeowner(owner).is_null());
+      const auto audit = get_nodeownerreg(owner);
+      BOOST_REQUIRE(!audit.is_null());
+      BOOST_CHECK_EQUAL(audit["status"].as<uint64_t>(), REJECTED);
+      BOOST_CHECK_EQUAL(audit["reason"].as<uint64_t>(), R_NAME_INVALID);
+   }
+} FC_LOG_AND_RETHROW()
+
 // Valid-for-tier name that is not an account -> REJECTED/OWNER_NOT_ACCOUNT.
 BOOST_FIXTURE_TEST_CASE( nodeownreg_owner_not_account, sysio_roa_nodeownreg_tester ) try {
    const auto owner = "ghost"_n;   // 5 chars: valid for tier 1, but no account was created
diff --git a/contracts/tests/sysio.system_authmsg_tests.cpp b/contracts/tests/sysio.system_authmsg_tests.cpp
deleted file mode 100644
index f2e046af08..0000000000
--- a/contracts/tests/sysio.system_authmsg_tests.cpp
+++ /dev/null
@@ -1,134 +0,0 @@
-#include <boost/test/unit_test.hpp>
-
-#include <sysio/chain/authorization_manager.hpp>
-#include <sysio/chain/permission_object.hpp>
-#include <sysio/chain/wast_to_wasm.hpp>
-
-#include <sstream>
-#include <string>
-
-#include "sysio.system_tester.hpp"
-
-using namespace sysio;
-using namespace sysio::chain;
-using namespace sysio::testing;
-using namespace sysio_system;
-
-/** Wire representation of auth.msg::onlinkauth's notification payload. */
-struct authmsg_onlinkauth {
-   name            user;
-   name            permission;
-   public_key_type pub_key;
-};
-
-FC_REFLECT( authmsg_onlinkauth, (user)(permission)(pub_key) )
-
-namespace {
-
-constexpr auto auth_msg_account    = "auth.msg"_n;
-constexpr auto auth_ext_permission = "auth.ext"_n;
-constexpr auto onlinkauth_action   = "onlinkauth"_n;
-
-/** Build a minimal auth.msg stand-in that notifies sysio for every action. */
-std::vector<uint8_t> authmsg_notification_wasm() {
-   std::ostringstream wast;
-   wast << R"((module
-      (import "env" "require_recipient" (func $require_recipient (param i64)))
-      (func (export "apply") (param i64 i64 i64)
-         (call $require_recipient (i64.const 0x)"
-        << std::hex << config::system_account_name.value << R"())
-      )
-   ))";
-   return wast_to_wasm( wast.str() );
-}
-
-/** System-contract fixture with a notifying contract installed on auth.msg. */
-struct authmsg_notification_tester : sysio_system_tester {
-   authmsg_notification_tester() {
-      create_account( auth_msg_account );
-      set_code( auth_msg_account, authmsg_notification_wasm() );
-      produce_block();
-   }
-
-   /** Send auth.msg::onlinkauth and return the transaction result. */
-   action_result notify_link( name permission, const public_key_type& pub_key ) {
-      action notification;
-      notification.account = auth_msg_account;
-      notification.name    = onlinkauth_action;
-      notification.data = fc::raw::pack(
-         authmsg_onlinkauth{ config::system_account_name, permission, pub_key } );
-      return push_contract_paid_action( std::move( notification ), auth_msg_account.value );
-   }
-
-   /** Find one permission on the system account affected by the privileged inline action. */
-   const permission_object* find_system_permission( name permission ) const {
-      return control->get_authorization_manager().find_permission( { config::system_account_name, permission } );
-   }
-};
-
-} // namespace
-
-BOOST_AUTO_TEST_SUITE( sysio_system_authmsg_tests )
-
-/** The reserved auth.ext permission is created below active and can be rotated. */
-BOOST_FIXTURE_TEST_CASE( installs_and_rotates_reserved_auth_ext_permission, authmsg_notification_tester ) try {
-   const auto first_key  = get_public_key( config::system_account_name, "auth-ext-first" );
-   const auto second_key = get_public_key( config::system_account_name, "auth-ext-second" );
-
-   BOOST_REQUIRE_EQUAL( success(), notify_link( auth_ext_permission, first_key ) );
-
-   const auto* active   = find_system_permission( config::active_name );
-   const auto* auth_ext = find_system_permission( auth_ext_permission );
-   BOOST_REQUIRE( active != nullptr );
-   BOOST_REQUIRE( auth_ext != nullptr );
-   BOOST_CHECK( auth_ext->parent == active->id );
-   const auto first_authority = auth_ext->auth.to_authority();
-   BOOST_CHECK_EQUAL( first_authority.threshold, 1u );
-   BOOST_REQUIRE_EQUAL( first_authority.keys.size(), 1u );
-   BOOST_CHECK( first_authority.keys.front().key == first_key );
-   BOOST_CHECK( first_authority.accounts.empty() );
-
-   BOOST_REQUIRE_EQUAL( success(), notify_link( auth_ext_permission, second_key ) );
-   auth_ext = find_system_permission( auth_ext_permission );
-   BOOST_REQUIRE( auth_ext != nullptr );
-   const auto second_authority = auth_ext->auth.to_authority();
-   BOOST_REQUIRE_EQUAL( second_authority.keys.size(), 1u );
-   BOOST_CHECK( second_authority.keys.front().key == second_key );
-} FC_LOG_AND_RETHROW()
-
-/** Privileged owner and active authorities cannot be targeted through the notification payload. */
-BOOST_FIXTURE_TEST_CASE( rejects_owner_and_active_permissions, authmsg_notification_tester ) try {
-   const auto attacker_key = get_public_key( config::system_account_name, "attacker" );
-   const auto* owner        = find_system_permission( config::owner_name );
-   const auto* active       = find_system_permission( config::active_name );
-   BOOST_REQUIRE( owner != nullptr );
-   BOOST_REQUIRE( active != nullptr );
-   const auto owner_before  = owner->auth.to_authority();
-   const auto active_before = active->auth.to_authority();
-
-   BOOST_REQUIRE_EQUAL( wasm_assert_msg( "onlinkauth may only update auth.ext" ),
-                        notify_link( config::owner_name, attacker_key ) );
-   BOOST_REQUIRE_EQUAL( wasm_assert_msg( "onlinkauth may only update auth.ext" ),
-                        notify_link( config::active_name, attacker_key ) );
-
-   owner  = find_system_permission( config::owner_name );
-   active = find_system_permission( config::active_name );
-   BOOST_REQUIRE( owner != nullptr );
-   BOOST_REQUIRE( active != nullptr );
-   const auto owner_authority  = owner->auth.to_authority();
-   const auto active_authority = active->auth.to_authority();
-   BOOST_CHECK( owner_authority == owner_before );
-   BOOST_CHECK( active_authority == active_before );
-} FC_LOG_AND_RETHROW()
-
-/** The whitelist also rejects non-critical but non-reserved child permission names. */
-BOOST_FIXTURE_TEST_CASE( rejects_arbitrary_child_permission, authmsg_notification_tester ) try {
-   constexpr auto arbitrary_permission = "session"_n;
-
-   BOOST_REQUIRE_EQUAL( wasm_assert_msg( "onlinkauth may only update auth.ext" ),
-                        notify_link( arbitrary_permission,
-                                     get_public_key( config::system_account_name, "session" ) ) );
-   BOOST_CHECK( find_system_permission( arbitrary_permission ) == nullptr );
-} FC_LOG_AND_RETHROW()
-
-BOOST_AUTO_TEST_SUITE_END()
diff --git a/tutorials/sig-em-tutorial/clone-repos.sh b/tutorials/sig-em-tutorial/clone-repos.sh
deleted file mode 100755
index 4ef2381f24..0000000000
--- a/tutorials/sig-em-tutorial/clone-repos.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-mkdir contracts &&  cd contracts
-
-git clone https://gitea.gitgo.app/Wire/auth.msg
-git clone https://gitea.gitgo.app/Wire/settle.wns
-
-cd ..
\ No newline at end of file
diff --git a/tutorials/sig-em-tutorial/prepare-sig-em.sh b/tutorials/sig-em-tutorial/prepare-sig-em.sh
deleted file mode 100755
index bd70e9005e..0000000000
--- a/tutorials/sig-em-tutorial/prepare-sig-em.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/bash
-
-# Issue tokens
-clio -u http://0.0.0.0:8000 push action sysio.token issue '[ "sysio", "1000000000.0000 SYS", "Issue" ]' -p sysio@active
-
-# Create accounts
-clio wallet import --private-key 5JJPh8dLzbz1XYnGoUaCr3LQDj9aWeA52sqGQgqgarYKYpYzsbY
-clio -u http://0.0.0.0:8000 system newaccount sysio settle.wns SYS5bVXRvVGsAfDWCQ1v5m5JRx42AAy6HtskiBpYXxedW8wxGeShb SYS5bVXRvVGsAfDWCQ1v5m5JRx42AAy6HtskiBpYXxedW8wxGeShb --stake-net '1000.0000 SYS' --stake-cpu '1000.0000 SYS'  --buy-ram-kbytes 2048
-clio -u http://0.0.0.0:8000 system newaccount sysio auth.msg SYS5bVXRvVGsAfDWCQ1v5m5JRx42AAy6HtskiBpYXxedW8wxGeShb SYS5bVXRvVGsAfDWCQ1v5m5JRx42AAy6HtskiBpYXxedW8wxGeShb --stake-net '1000.0000 SYS' --stake-cpu '1000.0000 SYS'  --buy-ram-kbytes 2048
-
-# Compile and deploy auth.msg contract
-cd contracts/auth.msg
-# source ./build.sh
-source ./deploy.sh
-
-# Compile and deploy settle.wns contract
-cd ../settle.wns
-# source ./build.sh
-source ./deploy.sh
-
-# Allow auth.msg to add auth.ext permissions on accounts
-clio -u http://0.0.0.0:8000 push action sysio updateauth '{ "account": "auth.msg", "permission": "owner", "parent": "", "auth": { "threshold": 1, 
-    "keys": [{
-        "key": "PUB_K1_5bVXRvVGsAfDWCQ1v5m5JRx42AAy6HtskiBpYXxedW8wwJWhhS", 
-        "weight": 1
-    }], "accounts": [{ "permission": { "actor": "auth.msg", "permission": "sysio.code" }, "weight": 1 }], "waits": []}}' -p auth.msg@owner
-
-# Init settle contract
-clio -u http://0.0.0.0:8000 push action settle.wns initcontract '[]' -p settle.wns@active
-
-# HELPERS
-
-# clio -u http://0.0.0.0:8000 system newaccount sysio ikdfsdhpun.x PUB_EM_8exffEtJ8SsyG5kiwAPwACqLf29ygzbtxoqBFdWTrWB6qxCYGE PUB_EM_8exffEtJ8SsyG5kiwAPwACqLf29ygzbtxoqBFdWTrWB6qxCYGE --stake-net '100.0000 SYS' --stake-cpu '100.0000 SYS'  --buy-ram-kbytes 1024
-# clio -u http://0.0.0.0:8000 system newaccount sysio qhzesdrkcdls PUB_EM_6hx8XEjettHeTg2W2vaw66577g1mwDg2oz4mtkpfx54UqEM99C PUB_EM_6hx8XEjettHeTg2W2vaw66577g1mwDg2oz4mtkpfx54UqEM99C --stake-net '100.0000 SYS' --stake-cpu '100.0000 SYS'  --buy-ram-kbytes 1024
-# clio -u http://0.0.0.0:8000 system newaccount sysio .pbqcja3oeej PUB_EM_6iPWEcH1tgQTjPNCRYfrVBx2MhGv4onomyfybupcUmW5LBHQqt PUB_EM_6iPWEcH1tgQTjPNCRYfrVBx2MhGv4onomyfybupcUmW5LBHQqt --stake-net '100.0000 SYS' --stake-cpu '100.0000 SYS'  --buy-ram-kbytes 1024
-
-
-# clio -u http://0.0.0.0:8000 system delegatebw sysio settle.wns "100.0000 SYS" "100.0000 SYS" -p sysio@active
-# clio -u http://0.0.0.0:8000 system delegatebw sysio auth.msg "40.0000 SYS" "40.0000 SYS" -p sysio@active
-# clio -u http://0.0.0.0:8000 system delegatebw sysio ikdfsdhpun.x "100.0000 SYS" "100.0000 SYS" -p sysio@active
-
-# clio -u http://0.0.0.0:8000 system buyram sysio settle.wns "100.0000 SYS" -p sysio@active
-# clio -u http://0.0.0.0:8000 system buyram sysio auth.msg "100.0000 SYS" -p sysio@active
-
-# clio -u http://0.0.0.0:8000 system buyram sysio ikdfsdhpun.x "10.0000 SYS" -p sysio@active
\ No newline at end of file
diff --git a/tutorials/sig-em-tutorial/unlock.sh b/tutorials/sig-em-tutorial/unlock.sh
deleted file mode 100755
index 86135837f4..0000000000
--- a/tutorials/sig-em-tutorial/unlock.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-
-# Read the password from the wallet.pass file
-PASSWORD=$(cat ../bios-boot-tutorial/wallet.pwd)
-
-# Use the password to unlock the wallet
-clio wallet unlock --password "$PASSWORD"
\ No newline at end of file

Change-Id: I74dc8d9c48b72d95e6ca88cd586d3607d53f7531
Change-Id: Ifaa3f15b3cf85414c9d1534c64b8abe13dddaaf4
Change-Id: If0257c62d0fe4a633ec171ce67f10aceccdd4e37
@huangminghuang

huangminghuang commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Addressed in be77471. The obsolete auth.msg::onlinkauth handler and auth_ext_permission were removed, sysio.* node-owner names are rejected through the shared ROA name validator, the requested dispatch/ROA regressions were added, generated contract artifacts were synchronized, and the stale sig-em tutorial was removed. Validation is green: Release ON/ON build, artifact parity, all 764 contract cases, monitored local flow-node-owner-nft, exact-head PR CI, and the exact-revision remote flow-node-owner-nft run.

@huangminghuang huangminghuang changed the title WIRE-360: Restrict auth.msg permission updates WIRE-360: Remove unsafe auth notification path Sep 16, 2026
@heifner

heifner commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Thanks. be774714db matches the verified patch, and every committed wasm at 72d7c28822 rebuilds byte-identical from source with the current CDT; the msgch/uwrit ABI additions match master's. One thing before merge: master has moved (#627, #610) and the branch now conflicts. In sysio.roa.cpp, keep both sets of anonymous-namespace constants, and please rebuild sysio.roa.wasm and sysio.system.wasm from the merged source rather than taking either side. I'll approve once that's in.

Change-Id: Iba8ecf00b26d1e4777c26418c0c001b563fab4c7
Change-Id: Iab4f4f73ec8e9d1969d62c97f3d82e3c97dd8d49
@huangminghuang

Copy link
Copy Markdown
Contributor Author

Resolved in 7719a74db4 and 86d28c4615. The branch now merges current master cleanly; sysio.roa.cpp retains both WIRE-360’s reserved sysio. prefix constant and master’s account-name generator/SplitMix constants. I rebuilt from the combined source and synchronized both sysio.roa.wasm and sysio.system.wasm rather than taking either conflict side. Validation is green: Release ON/ON full build, complete artifact parity, all 783 contract cases, monitored local flow-node-owner-nft, exact-head PR CI, and the full 15-flow remote platform run. The PR is mergeable and its description is current.

@huangminghuang
huangminghuang merged commit 29e9aee into master Sep 21, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants