diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index cd73fc7d52..5868b3529a 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -2542,6 +2542,92 @@ struct Export_test : public beast::unit_test::suite } } + void + testExportCallbackAccountAuthorization(FeatureBitset features) + { + using namespace jtx; + for (std::string const mode : {"master", "regular", "multisign"}) + { + testcase("Export callback source-account authorization: " + mode); + auto const xpopCtx = xpop::TestXPOPContext::create(3); + Account const alice{"alice"}; + Account const carol{"carol"}; + Account const dave{"dave"}; + Env env{*this, xpopCtx.makeEnvConfig(21337), features}; + env.fund(XRP(10000), alice, carol, dave); + env.close(); + auto const callback = + buildExportCallbackXPOP(env, xpopCtx, alice, carol, 31337, 2); + auto const latchKey = + keylet::exportLatch(alice.id(), callback.originTxn); + BEAST_EXPECT(env.current()->exists(latchKey)); + + if (mode == "regular") + { + env(regkey(alice, carol)); + env(fset(alice, asfDisableMaster)); + } + else if (mode == "multisign") + { + env(signers(alice, 2, {{carol, 1}, {dave, 1}})); + env(fset(alice, asfDisableMaster)); + } + env.close(); + + auto const before = + env.current()->read(keylet::account(alice.id())); + auto const balance = before->getFieldAmount(sfBalance).xrp(); + auto const sequence = before->getFieldU32(sfSequence); + auto const feeDrops = env.current()->fees().base * 10; + auto const reject = [&](auto const& signing, TER const result) { + env(import::import(alice, callback.xpopJson), + signing, + fee(feeDrops), + ter(result)); + auto const after = + env.current()->read(keylet::account(alice.id())); + BEAST_EXPECT(after->getFieldAmount(sfBalance).xrp() == balance); + BEAST_EXPECT(after->getFieldU32(sfSequence) == sequence); + BEAST_EXPECT(env.current()->exists(latchKey)); + if (callback.vlInfo) + BEAST_EXPECT( + importVLSequence(env, callback.vlInfo->second) == 0); + }; + + reject(sig(dave), tefBAD_AUTH); + if (mode == "master") + { + reject(msig(carol), tefNOT_MULTI_SIGNING); + env(import::import(alice, callback.xpopJson), + sig(alice), + fee(feeDrops), + ter(tesSUCCESS)); + } + else if (mode == "regular") + { + reject(sig(alice), tefMASTER_DISABLED); + env(import::import(alice, callback.xpopJson), + sig(carol), + fee(feeDrops), + ter(tesSUCCESS)); + } + else + { + reject(sig(alice), tefMASTER_DISABLED); + reject(msig(carol), tefBAD_QUORUM); + env(import::import(alice, callback.xpopJson), + msig(carol, dave), + fee(feeDrops), + ter(tesSUCCESS)); + } + auto const after = env.current()->read(keylet::account(alice.id())); + BEAST_EXPECT( + after->getFieldAmount(sfBalance).xrp() == balance - feeDrops); + BEAST_EXPECT(after->getFieldU32(sfSequence) == sequence + 1); + BEAST_EXPECT(!env.current()->exists(latchKey)); + } + } + void testCanceledExportAcceptsMatchingImport(FeatureBitset features) { @@ -2691,6 +2777,7 @@ struct Export_test : public beast::unit_test::suite // Round-trip test testExportImportRoundTrip(allWithExport); + testExportCallbackAccountAuthorization(allWithExport); testCanceledExportAcceptsMatchingImport(allWithExport); testExportImportRejectsStaleImportVL(allWithExport); } diff --git a/src/xrpld/app/consensus/ExportIntent.md b/src/xrpld/app/consensus/ExportIntent.md index 01f7467e2d..d299440bd6 100644 --- a/src/xrpld/app/consensus/ExportIntent.md +++ b/src/xrpld/app/consensus/ExportIntent.md @@ -283,6 +283,12 @@ transaction's `sfAccount`, which Export admission already bound to the exporter and latch owner. Possession of an XPOP alone never authorizes another account's callback. +The source account's enabled master key, current regular key, or configured +multisigning quorum authorizes the outer Import and its fee/sequence effects. +An unrelated signing key must fail before balance, sequence, latch, or Import +validator-list state changes. The target committee's signatures and possession +of its valid XPOP do not substitute for source-account authorization. + The callback may omit the burn-to-mint `sfOperationLimit` and outer/inner signing-key-equality checks because the validator-multisigned target and Export latch replace those bindings. It still requires a fully canonical target diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index e9310c1740..4432db5740 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -908,11 +909,20 @@ Transactor::checkSign(PreclaimContext const& ctx) ctx.tx.getFieldU32(sfNetworkID) == 65535) return tesSUCCESS; - // pass ttIMPORTs, their signatures are checked at the preflight against the - // internal xpop txn + // Burn-to-mint Imports retain their XPOP-based signing authorization. + // Export callbacks instead authorize the outer source-account transaction + // normally: target committee signatures do not authorize its fee/sequence. if (ctx.view.rules().enabled(featureImport) && ctx.tx.getTxnType() == ttIMPORT) - return tesSUCCESS; + { + if (!ctx.view.rules().enabled(featureExport)) + return tesSUCCESS; + auto const [inner, meta] = Import::getInnerTxn(ctx.tx, ctx.j); + if (!inner || !meta) + return temMALFORMED; + if (!inner->isFieldPresent(sfTicketSequence)) + return tesSUCCESS; + } // pass ttMANIFEST_SETs, their signatures are checked in preflight against // the manifest's internal key logic