Skip to content

Commit 2628de7

Browse files
committed
Merge bitcoin/bitcoin#33135: wallet: warn against accidental unsafe older() import
76c092f wallet: warn against accidental unsafe older() import (Sjors Provoost) 592157b test: move SEQUENCE_LOCKTIME flags to script (Sjors Provoost) Pull request description: [BIP 379](https://github.com/bitcoin/bips/blob/master/bip-0379.md) ([Miniscript](https://bitcoin.sipa.be/miniscript/)) allows relative height and time locks that have no consensus meaning in [BIP 68](https://github.com/bitcoin/bips/blob/master/bip-0068.mediawiki) (relative timelocks) / [BIP 112](https://github.com/bitcoin/bips/blob/master/bip-0112.mediawiki) (`CHECKSEQUENCEVERIFY`). This is (ab)used by some protocols, e.g. [by Lightning to encode extra data](https://delvingbitcoin.org/t/exploring-extended-relative-timelocks/1818/23), but is unsafe when used unintentionally: `older(65536)` is equivalent to `older(1)`. This PR emits a warning when `importdescriptors` contains such a descriptor. The first commit makes `SEQUENCE_LOCKTIME` flags reusable by other tests. The main commit adds the `ForEachNode` helper to `miniscript.h` which is then used in the `MiniscriptDescriptor` constructor to check for `Fragment::OLDER` with unsafe values. These are stored in `m_warnings`, which the RPC code then collects via `Warnings()`. It adds both a unit and functional test. --- A previous version of this PR prevented the import, unless the user opted in with an `unsafe` flag. It also used string parsing in the RPC code. --- Based on: - [x] bitcoin/bitcoin#33914 ACKs for top commit: pythcoiner: reACK 76c092f achow101: ACK 76c092f rkrux: lgtm re-ACK 76c092f brunoerg: reACK 76c092f Tree-SHA512: 8e944e499bd4a43cc27eeb889f262b499b9b07aa07610f4a415ccb4e34a9110f9946646f446a54ac5bf17494d8d96a89e4a1fa278385db9b950468f27283e17a
2 parents c631553 + 76c092f commit 2628de7

File tree

9 files changed

+147
-6
lines changed

9 files changed

+147
-6
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/feature_bip68_sequence.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from test_framework.script import (
2525
CScript,
2626
OP_TRUE,
27+
SEQUENCE_LOCKTIME_DISABLE_FLAG,
28+
SEQUENCE_LOCKTIME_TYPE_FLAG,
29+
SEQUENCE_LOCKTIME_GRANULARITY,
30+
SEQUENCE_LOCKTIME_MASK,
2731
)
2832
from test_framework.test_framework import BitcoinTestFramework
2933
from test_framework.util import (
@@ -36,11 +40,6 @@
3640

3741
SCRIPT_W0_SH_OP_TRUE = script_to_p2wsh_script(CScript([OP_TRUE]))
3842

39-
SEQUENCE_LOCKTIME_DISABLE_FLAG = (1<<31)
40-
SEQUENCE_LOCKTIME_TYPE_FLAG = (1<<22) # this means use time (0 means height)
41-
SEQUENCE_LOCKTIME_GRANULARITY = 9 # this is a bit-shift
42-
SEQUENCE_LOCKTIME_MASK = 0x0000ffff
43-
4443
# RPC error for non-BIP68 final transactions
4544
NOT_FINAL_ERROR = "non-BIP68-final"
4645

test/functional/test_framework/script.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
LOCKTIME_THRESHOLD = 500000000
2929
ANNEX_TAG = 0x50
3030

31+
SEQUENCE_LOCKTIME_DISABLE_FLAG = (1<<31)
32+
SEQUENCE_LOCKTIME_TYPE_FLAG = (1<<22) # this means use time (0 means height)
33+
SEQUENCE_LOCKTIME_GRANULARITY = 9 # this is a bit-shift
34+
SEQUENCE_LOCKTIME_MASK = 0x0000ffff
35+
3136
LEAF_VERSION_TAPSCRIPT = 0xc0
3237

3338
def hash160(s):

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+
f"time-based relative locktime: older({unsafe_value}) > (65535 * 512) seconds is unsafe"
808+
if flag == SEQUENCE_LOCKTIME_TYPE_FLAG
809+
else f"height-based relative locktime: older({unsafe_value}) > 65535 blocks 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)