Skip to content

Commit 45eb536

Browse files
committed
wallet: warn against accidental unsafe older() import
BIP 379 allows height and time locks that have no consensus meaning in BIP 68 / BIP 112. This is used by some protocols like Lightning to encode extra data, but is unsafe when used unintentionally. E.g. older(65536) is equivalent to older(1). This commit emits a warning when importing such a descriptor. It introduces a helper ForEachNode to traverse all miniscript nodes.
1 parent 592157b commit 45eb536

File tree

7 files changed

+138
-1
lines changed

7 files changed

+138
-1
lines changed

src/script/descriptor.cpp

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,8 @@ class DescriptorImpl : public Descriptor
791791
const std::vector<std::unique_ptr<PubkeyProvider>> m_pubkey_args;
792792
//! The string name of the descriptor function.
793793
const std::string m_name;
794+
//! Warnings (not including subdescriptors).
795+
std::vector<std::string> m_warnings;
794796

795797
//! The sub-descriptor arguments (empty for everything but SH and WSH).
796798
//! In doc/descriptors.m this is referred to as SCRIPT expressions sh(SCRIPT)
@@ -990,6 +992,16 @@ class DescriptorImpl : public Descriptor
990992
}
991993

992994
virtual std::unique_ptr<DescriptorImpl> Clone() const = 0;
995+
996+
// NOLINTNEXTLINE(misc-no-recursion)
997+
std::vector<std::string> Warnings() const override {
998+
std::vector<std::string> all = m_warnings;
999+
for (const auto& sub : m_subdescriptor_args) {
1000+
auto sub_w = sub->Warnings();
1001+
all.insert(all.end(), sub_w.begin(), sub_w.end());
1002+
}
1003+
return all;
1004+
}
9931005
};
9941006

9951007
/** A parsed addr(A) descriptor. */
@@ -1537,7 +1549,24 @@ class MiniscriptDescriptor final : public DescriptorImpl
15371549

15381550
public:
15391551
MiniscriptDescriptor(std::vector<std::unique_ptr<PubkeyProvider>> providers, miniscript::NodeRef<uint32_t> node)
1540-
: DescriptorImpl(std::move(providers), "?"), m_node(std::move(node)) {}
1552+
: DescriptorImpl(std::move(providers), "?"), m_node(std::move(node))
1553+
{
1554+
// Traverse miniscript tree for unsafe use of older()
1555+
miniscript::ForEachNode(*m_node, [&](const miniscript::Node<uint32_t>& node) {
1556+
if (node.fragment == miniscript::Fragment::OLDER) {
1557+
const uint32_t raw = node.k;
1558+
const uint32_t value_part = raw & ~CTxIn::SEQUENCE_LOCKTIME_TYPE_FLAG;
1559+
if (value_part > CTxIn::SEQUENCE_LOCKTIME_MASK) {
1560+
const bool is_time_based = (raw & CTxIn::SEQUENCE_LOCKTIME_TYPE_FLAG) != 0;
1561+
if (is_time_based) {
1562+
m_warnings.push_back(strprintf("time-based relative locktime: older(%u) > (65535 * 512) seconds is unsafe", raw));
1563+
} else {
1564+
m_warnings.push_back(strprintf("height-based relative locktime: older(%u) > 65535 blocks is unsafe", raw));
1565+
}
1566+
}
1567+
}
1568+
});
1569+
}
15411570

15421571
bool ToStringHelper(const SigningProvider* arg, std::string& out, const StringType type,
15431572
const DescriptorCache* cache = nullptr) const override

src/script/descriptor.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ struct Descriptor {
165165
* @param[out] ext_pubs Any extended public keys
166166
*/
167167
virtual void GetPubKeys(std::set<CPubKey>& pubkeys, std::set<CExtPubKey>& ext_pubs) const = 0;
168+
169+
/** Semantic/safety warnings (includes subdescriptors). */
170+
virtual std::vector<std::string> Warnings() const = 0;
168171
};
169172

170173
/** Parse a `descriptor` string. Included private keys are put in `out`.

src/script/miniscript.h

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
#include <algorithm>
99
#include <compare>
10+
#include <concepts>
1011
#include <cstdint>
1112
#include <cstdlib>
13+
#include <functional>
1214
#include <iterator>
1315
#include <memory>
1416
#include <optional>
@@ -195,6 +197,21 @@ template<typename Key> using NodeRef = std::unique_ptr<const Node<Key>>;
195197
template<typename Key, typename... Args>
196198
NodeRef<Key> MakeNodeRef(Args&&... args) { return std::make_unique<const Node<Key>>(std::forward<Args>(args)...); }
197199

200+
//! Unordered traversal of a miniscript node tree.
201+
template <typename Key, std::invocable<const Node<Key>&> Fn>
202+
void ForEachNode(const Node<Key>& root, Fn&& fn)
203+
{
204+
std::vector<std::reference_wrapper<const Node<Key>>> stack{root};
205+
while (!stack.empty()) {
206+
const Node<Key>& node = stack.back();
207+
std::invoke(fn, node);
208+
stack.pop_back();
209+
for (const auto& sub : node.subs) {
210+
stack.emplace_back(*sub);
211+
}
212+
}
213+
}
214+
198215
//! The different node types in miniscript.
199216
enum class Fragment {
200217
JUST_0, //!< OP_0

src/test/descriptor_tests.cpp

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,4 +1271,50 @@ BOOST_AUTO_TEST_CASE(descriptor_literal_null_byte)
12711271
BOOST_REQUIRE_MESSAGE(!descs.empty(), err);
12721272
}
12731273

1274+
BOOST_AUTO_TEST_CASE(descriptor_older_warnings)
1275+
{
1276+
// A safe boundary value should yield no warnings.
1277+
{
1278+
FlatSigningProvider keys;
1279+
std::string err;
1280+
auto descs = Parse("wsh(and_v(v:pk(0379e45b3cf75f9c5f9befd8e9506fb962f6a9d185ac87001ec44a8d3df8d4a9e3),older(65535)))", keys, err, /*require_checksum=*/false);
1281+
BOOST_REQUIRE_MESSAGE(!descs.empty(), err);
1282+
BOOST_CHECK(descs[0]->Warnings().empty());
1283+
}
1284+
1285+
// Height-based unsafe value (65536) should produce one warning.
1286+
{
1287+
FlatSigningProvider keys;
1288+
std::string err;
1289+
const uint32_t height_unsafe = 65536;
1290+
auto descs = Parse(strprintf("wsh(and_v(v:pk(0379e45b3cf75f9c5f9befd8e9506fb962f6a9d185ac87001ec44a8d3df8d4a9e3),older(%u)))", height_unsafe), keys, err, /*require_checksum=*/false);
1291+
BOOST_REQUIRE_MESSAGE(!descs.empty(), err);
1292+
const auto& ws = descs[0]->Warnings();
1293+
BOOST_REQUIRE_EQUAL(ws.size(), 1U);
1294+
BOOST_CHECK_EQUAL(ws[0], strprintf("height-based relative locktime: older(%u) > 65535 blocks is unsafe", height_unsafe));
1295+
}
1296+
1297+
// Time-based unsafe value: add SEQUENCE_LOCKTIME_TYPE_FLAG (1<<22)
1298+
{
1299+
FlatSigningProvider keys;
1300+
std::string err;
1301+
const uint32_t time_unsafe = 65536 | CTxIn::SEQUENCE_LOCKTIME_TYPE_FLAG;
1302+
auto descs = Parse(strprintf("wsh(and_v(v:pk(0379e45b3cf75f9c5f9befd8e9506fb962f6a9d185ac87001ec44a8d3df8d4a9e3),older(%u)))", time_unsafe), keys, err, /*require_checksum=*/false);
1303+
BOOST_REQUIRE_MESSAGE(!descs.empty(), err);
1304+
const auto& warnings = descs[0]->Warnings();
1305+
BOOST_REQUIRE_EQUAL(warnings.size(), 1U);
1306+
BOOST_CHECK_EQUAL(warnings[0], strprintf("time-based relative locktime: older(%u) > (65535 * 512) seconds is unsafe", time_unsafe));
1307+
}
1308+
1309+
// Ensure no false positive warnings for absolute timelocks
1310+
{
1311+
FlatSigningProvider keys;
1312+
std::string err;
1313+
// Using after() with a large timestamp (> 65535)
1314+
auto descs = Parse("wsh(and_v(v:pk(0379e45b3cf75f9c5f9befd8e9506fb962f6a9d185ac87001ec44a8d3df8d4a9e3),after(1000000)))", keys, err, /*require_checksum=*/false);
1315+
BOOST_REQUIRE_MESSAGE(!descs.empty(), err);
1316+
BOOST_CHECK(descs[0]->Warnings().empty());
1317+
}
1318+
}
1319+
12741320
BOOST_AUTO_TEST_SUITE_END()

src/wallet/rpc/backup.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ static UniValue ProcessDescriptorImport(CWallet& wallet, const UniValue& data, c
240240
}
241241
parsed_desc->ExpandPrivate(0, keys, expand_keys);
242242

243+
for (const auto& w : parsed_desc->Warnings()) {
244+
warnings.push_back(w);
245+
}
246+
243247
// Check if all private keys are provided
244248
bool have_all_privkeys = !expand_keys.keys.empty();
245249
for (const auto& entry : expand_keys.origins) {

src/wallet/test/walletload_tests.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class DummyDescriptor final : public Descriptor {
3535
std::optional<int64_t> MaxSatisfactionWeight(bool) const override { return {}; }
3636
std::optional<int64_t> MaxSatisfactionElems() const override { return {}; }
3737
void GetPubKeys(std::set<CPubKey>& pubkeys, std::set<CExtPubKey>& ext_pubs) const override {}
38+
std::vector<std::string> Warnings() const override { return {}; }
3839
};
3940

4041
BOOST_FIXTURE_TEST_CASE(wallet_load_descriptors, TestingSetup)

test/functional/wallet_importdescriptors.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from test_framework.blocktools import COINBASE_MATURITY
2323
from test_framework.test_framework import BitcoinTestFramework
2424
from test_framework.descriptors import descsum_create
25+
from test_framework.script import SEQUENCE_LOCKTIME_TYPE_FLAG
2526
from test_framework.util import (
2627
assert_equal,
2728
assert_raises_rpc_error,
@@ -58,6 +59,7 @@ def test_importdesc(self, req, success, error_code=None, error_message=None, war
5859
if 'warnings' in result[0]:
5960
observed_warnings = result[0]['warnings']
6061
assert_equal("\n".join(sorted(warnings)), "\n".join(sorted(observed_warnings)))
62+
self.log.debug(result)
6163
assert_equal(result[0]['success'], success)
6264
if error_code is not None:
6365
assert_equal(result[0]['error']['code'], error_code)
@@ -783,5 +785,40 @@ def run_test(self):
783785
assert_equal(w_multipath.getrawchangeaddress(address_type="bech32"), w_multisplit.getrawchangeaddress(address_type="bech32"))
784786
assert_equal(sorted(w_multipath.listdescriptors()["descriptors"], key=lambda x: x["desc"]), sorted(w_multisplit.listdescriptors()["descriptors"], key=lambda x: x["desc"]))
785787

788+
self.log.info("Test older() safety")
789+
790+
for flag in [0, SEQUENCE_LOCKTIME_TYPE_FLAG]:
791+
self.log.debug("Importing a safe value always works")
792+
safe_value = (65535 | flag)
793+
self.test_importdesc(
794+
{
795+
'desc': descsum_create(f"wsh(and_v(v:pk([12345678/0h/0h]{xpub}/*),older({safe_value})))"),
796+
'active': True,
797+
'range': [0, 2],
798+
'timestamp': 'now'
799+
},
800+
success=True
801+
)
802+
803+
self.log.debug("Importing an unsafe value results in a warning")
804+
unsafe_value = safe_value + 1
805+
desc = descsum_create(f"wsh(and_v(v:pk([12345678/0h/0h]{xpub}/*),older({unsafe_value})))")
806+
expected_warning = (
807+
"relative locktime > 65535 * 512 seconds is unsafe"
808+
if flag == SEQUENCE_LOCKTIME_TYPE_FLAG
809+
else f"older({unsafe_value}) > 65535 is unsafe"
810+
)
811+
self.test_importdesc(
812+
{
813+
'desc': desc,
814+
'active': True,
815+
'range': [0, 2],
816+
'timestamp': 'now'
817+
},
818+
success=True,
819+
warnings=[expected_warning],
820+
)
821+
822+
786823
if __name__ == '__main__':
787824
ImportDescriptorsTest(__file__).main()

0 commit comments

Comments
 (0)