From 57abf5870bb63e071323dbb81488eb56a827dc98 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 10:17:55 +0300 Subject: [PATCH 01/21] Widen charge_type to 32 bits The CIP-11 charge and the CIP-1852 role accept any level index, but charge_type was std::uint8_t, so the parsed value was truncated to a byte: m/1852'/1815'/0'/256/0 parsed as role 0, re-serialised as m/1852'/1815'/0'/0/0 and returned role 0 in levels(). Two URNs named the same key. charge_type is now std::uint32_t and keeps the full value in charge(), levels() and str(). Mirrors go-mhda. This changes the ABI of derivation_path; CHANGELOG notes it under 1.2.0. --- CHANGELOG.md | 13 +++++++++++++ include/mhda/derivation_path.hpp | 6 +++++- tests/test_derivation_path.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f09c5..c6c80ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] — 2026-10-05 + +### Fixed + +- **`charge_type` is 32 bits wide (was `std::uint8_t`).** The CIP-11 charge + and the CIP-1852 role accept any level index, but the parsed value was + truncated to a byte: `m/1852'/1815'/0'/256/0` parsed as role 0, so it + named the role-0 key, re-serialised as `m/1852'/1815'/0'/0/0`, and + `levels()` returned the truncated value. The full value is now kept in + `charge()`, `levels()` and `str()`. This changes the ABI of + `derivation_path` (its constructor, `charge()` and its layout); rebuild + dependants. + ## [1.1.0] — 2026-07-04 URN grammar 1.1, mirroring the go-mhda reference. The chain identity is now diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index fa3aa7a..387a5e5 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -11,7 +11,11 @@ namespace mhda { using account_index = std::uint32_t; -using charge_type = std::uint8_t; +// charge_type is the level after the account: the change level of BIP-32 and +// the BIP-44 family (0 external, 1 internal), the CIP-11 charge and the +// CIP-1852 role. It is as wide as a level index: the CIP-11 charge and the +// CIP-1852 role take any index. +using charge_type = std::uint32_t; constexpr charge_type charge_external = 0; constexpr charge_type charge_internal = 1; diff --git a/tests/test_derivation_path.cpp b/tests/test_derivation_path.cpp index 9c54c9f..870ea5e 100644 --- a/tests/test_derivation_path.cpp +++ b/tests/test_derivation_path.cpp @@ -58,6 +58,30 @@ TEST_CASE("CIP-1852 levels exposed correctly") { for (std::size_t i = 0; i < want.size(); ++i) EXPECT_EQ(got[i], want[i]); } +// The CIP-11 charge and the CIP-1852 role take any level index. A value above +// 255 must keep its full width: truncated to a byte, role 256 would name the +// role-0 key. +TEST_CASE("CIP-11 charge and CIP-1852 role keep their full width") { + struct row { std::string urn; std::uint32_t want; }; + std::vector cases = { + {"urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/256/0", 256}, + {"urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/2147483647/0", 2147483647}, + {"urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/257/0", 257}, + {"urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/65536/0", 65536}, + }; + for (const auto& c : cases) { + auto addr = parse_urn_strict(c.urn); + EXPECT_EQ(addr.str(), c.urn); + EXPECT_EQ(std::uint32_t(addr.path()->charge()), c.want); + EXPECT_EQ(addr.path()->levels()[3], (address_index{c.want, false})); + } + + const std::uint32_t role = 300; + derivation_path dp{derivation_type::cip1852, coins::ada, 0, charge_type(role), + address_index{1, false}}; + EXPECT_EQ(dp.str(), std::string{"m/1852'/1815'/0'/300/1"}); +} + TEST_CASE("SLIP-10 mixed hardening round-trip") { const std::string urn = "urn:mhda:nt:evm:ci:mainnet:ct:0:dt:slip10:dp:m/44'/0'/0'/0/5"; auto addr = parse_urn(urn); From ba449590c6ae3a4fb9a386bc657aea9f64241106 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 10:18:52 +0300 Subject: [PATCH 02/21] Require exact spelling of fixed path levels The purpose, the fixed coin of cip11/cip1852/zip32 and the 0|1 charge of bip32 and the BIP-44 family are literals in the go-mhda regexes, so Go refuses a leading zero there (m/044'/60'/0'/0/0, m/44'/60'/0'/00/0, m/1852'/01815'/0'/0/0). The port compared the parsed values only and accepted them: a URN Go refuses parsed here. Each level now records whether its digits carry a leading zero, and the fixed levels refuse it with invalid_derivation_path. Variable levels still accept leading zeros and drop them in str(), as in Go. SPEC.md writes down the rule (identical text in go-mhda). --- CHANGELOG.md | 10 ++++++++++ SPEC.md | 6 ++++++ src/derivation_path.cpp | 19 +++++++++++++++---- tests/test_boundary.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c80ae..2811c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, + the fixed coin of `cip11` / `cip1852` / `zip32` and the `0`/`1` charge of + `bip32` and the BIP-44 family are literals in the Go reference's grammar; + the port compared their values only and so accepted a leading zero that + Go refuses (`m/044'/60'/0'/0/0`, `m/44'/60'/0'/00/0`, + `m/1852'/01815'/0'/0/0`). These now throw + `parse_error(invalid_derivation_path)`. Leading zeros in a variable level + (`m/44'/060'/0'/0/0`) are still accepted, as in Go, and dropped in the + canonical form. + - **`charge_type` is 32 bits wide (was `std::uint8_t`).** The CIP-11 charge and the CIP-1852 role accept any level index, but the parsed value was truncated to a byte: `m/1852'/1815'/0'/256/0` parsed as role 0, so it diff --git a/SPEC.md b/SPEC.md index a287470..2c005d2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -248,6 +248,12 @@ Hardening markers in input: `'`, `H`, or `h` are all accepted and normalised to `'` in canonical output. The trailing `[']` on `index` denotes that the leaf level itself may be hardened. +Leading zeros are accepted in a variable level and dropped in canonical +output (`m/44'/060'/0'/0/0` is `m/44'/60'/0'/0/0`); the range applies to the +value. A fixed level (the purpose, the fixed coin of `cip11`, `cip1852` and +`zip32`, the `0`/`1` charge of `bip32` and the BIP-44 family) must be spelled +exactly as in the template. + ### 3.1 Variable-length paths `slip10` accepts any number of levels and is the right type for chains whose diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 2609c8f..9dfffc7 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -15,6 +15,10 @@ namespace { struct raw_level { std::uint32_t index; bool is_hardened; + // exact is false when the digits carry a leading zero ("060"). A variable + // level accepts that; a fixed level (purpose, fixed coin, 0|1 charge) is + // a literal in the Go reference's regexes and must be spelled exactly. + bool exact; }; // is_hardening_marker reports whether c is one of the accepted hardening @@ -35,6 +39,7 @@ bool parse_segment(std::string_view seg, raw_level& out) noexcept { if (!detail::parse_uint32_dec(seg, value)) return false; out.index = value; out.is_hardened = hardened; + out.exact = seg.size() == 1 || seg[0] != '0'; return true; } @@ -67,14 +72,18 @@ bool parse_levels(std::string_view path, std::vector& levels) { return true; } -// expect_purpose verifies that level[0] is the given purpose (hardened). +// expect_purpose verifies that level[0] is the given purpose (hardened, +// spelled exactly). bool expect_purpose(const std::vector& lvls, std::uint32_t purpose) { - return !lvls.empty() && lvls[0].index == purpose && lvls[0].is_hardened; + return !lvls.empty() && lvls[0].index == purpose && lvls[0].is_hardened && + lvls[0].exact; } -// expect_coin verifies that level[1] is the given coin (hardened). +// expect_coin verifies that level[1] is the given coin (hardened, spelled +// exactly). bool expect_coin(const std::vector& lvls, std::uint32_t coin) { - return lvls.size() >= 2 && lvls[1].index == coin && lvls[1].is_hardened; + return lvls.size() >= 2 && lvls[1].index == coin && lvls[1].is_hardened && + lvls[1].exact; } // validate_bip44_family validates the 5-level BIP-44-shaped layout: @@ -91,6 +100,7 @@ bool validate_bip44_family(const std::vector& lvls, if (!lvls[2].is_hardened) return false; // account hardened if (lvls[3].is_hardened) return false; // charge soft if (lvls[3].index > 1) return false; // charge ∈ {0,1} + if (!lvls[3].exact) return false; // charge spelled 0 or 1 return true; // index leaf: arbitrary, hardening optional } @@ -100,6 +110,7 @@ bool validate_bip32(const std::vector& lvls) { if (!lvls[0].is_hardened) return false; if (lvls[1].is_hardened) return false; if (lvls[1].index > 1) return false; + if (!lvls[1].exact) return false; return true; } diff --git a/tests/test_boundary.cpp b/tests/test_boundary.cpp index a75ec37..d6835d1 100644 --- a/tests/test_boundary.cpp +++ b/tests/test_boundary.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include "mhda/mhda.hpp" @@ -132,6 +134,44 @@ TEST_CASE("derivation_path rejects double hardening marker") { error_code::invalid_derivation_path); } +TEST_CASE("derivation_path fixed levels are spelled exactly") { + // Mirrors the go-mhda regexes: the purpose, the fixed coin and the 0|1 + // charge are literals, so a leading zero there is refused. + const std::vector> refused = { + {derivation_type::bip44, "m/044'/60'/0'/0/0"}, + {derivation_type::bip84, "m/084'/0'/0'/0/0"}, + {derivation_type::bip44, "m/44'/60'/0'/00/0"}, + {derivation_type::bip44, "m/44'/60'/0'/01/0"}, + {derivation_type::bip32, "m/0'/00/0"}, + {derivation_type::cip11, "m/044'/118'/0'/0/0"}, + {derivation_type::cip11, "m/44'/0118'/0'/0/0"}, + {derivation_type::cip1852, "m/01852'/1815'/0'/0/0"}, + {derivation_type::cip1852, "m/1852'/01815'/0'/0/0"}, + {derivation_type::zip32, "m/032'/133'/0'"}, + {derivation_type::zip32, "m/32'/0133'/0'"}, + }; + for (const auto& r : refused) { + EXPECT_THROW_CODE(derivation_path::parse(r.first, r.second), + error_code::invalid_derivation_path); + EXPECT_FALSE(validate_derivation_path(r.first, r.second)); + } +} + +TEST_CASE("derivation_path variable levels accept leading zeros") { + // The value counts; the canonical form drops the zeros. + const std::vector> accepted = { + {derivation_type::bip44, "m/44'/060'/00'/0/007", "m/44'/60'/0'/0/7"}, + {derivation_type::bip32, "m/00'/1/007'", "m/0'/1/7'"}, + {derivation_type::cip11, "m/44'/118'/0'/09/0", "m/44'/118'/0'/9/0"}, + {derivation_type::cip1852, "m/1852'/1815'/0'/02/0", "m/1852'/1815'/0'/2/0"}, + {derivation_type::zip32, "m/32'/133'/00'/00'", "m/32'/133'/0'/0'"}, + {derivation_type::slip10, "m/044'/0501'/00'", "m/44'/501'/0'"}, + }; + for (const auto& r : accepted) { + EXPECT_EQ(derivation_path::parse(std::get<0>(r), std::get<1>(r)).str(), std::get<2>(r)); + } +} + TEST_CASE("derivation_path accepts SLIP-10 with single level") { auto dp = derivation_path::parse(derivation_type::slip10, "m/0"); EXPECT_EQ(dp.str(), std::string{"m/0"}); From 662082ab1737a76523df4b8f63bd51bee2e62b66 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 10:22:41 +0300 Subject: [PATCH 03/21] Refuse derivation-path levels with an index of 2^31 or more A BIP-32 child number keeps the hardened flag in its top bit: n' is child number 2^31+n, so a level index must stay below 2^31. parse_segment accepted any 32-bit value, and a consumer computing hardened ? 0x80000000|index : index got an alias: m/44'/60'/2147483648'/0/0 derived the key of m/44'/60'/0'/0/0, and an unhardened .../0/2147483648 asked for a hardened child number through the public-key formula. parse_uint32_dec becomes parse_uint31_dec, used by every level of every derivation type, so parse_urn, parse_nss, derivation_path::parse and validate_derivation_path refuse such a path with invalid_derivation_path. The ct component is not a path level and keeps its 32-bit range. Mirrors go-mhda. The boundary test and the fuzz seed that pinned 4294967295 now sit at the new bound, and the derivation-path fuzz checks that no accepted level exceeds 2^31-1. tests/data/dp_conformance.txt is the table shared verbatim with go-mhda (testdata/dp_conformance.txt): every row runs standalone, through validate_derivation_path and inside a URN, so a path one implementation refuses, the other refuses too. Version 1.2.0. SPEC.md (same text as go-mhda), CHANGELOG.md and README describe the rule and the URNs that no longer parse. --- CHANGELOG.md | 31 ++ CMakeLists.txt | 2 +- README.md | 13 +- SPEC.md | 12 +- include/mhda/derivation_path.hpp | 4 +- src/derivation_path.cpp | 6 +- src/detail.cpp | 4 +- src/detail.hpp | 8 +- tests/CMakeLists.txt | 4 + tests/data/dp_conformance.txt | 466 +++++++++++++++++++++++++++++++ tests/test_boundary.cpp | 20 +- tests/test_fuzz.cpp | 13 +- tests/test_index_range.cpp | 142 ++++++++++ 13 files changed, 696 insertions(+), 29 deletions(-) create mode 100644 tests/data/dp_conformance.txt create mode 100644 tests/test_index_range.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2811c09..806ff49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.2.0] — 2026-10-05 +URNs accepted by 1.1 are refused now: a derivation path with a level index +of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. + +### Changed + +- **A derivation-path level index of 2^31 or more is refused** with + `parse_error(invalid_derivation_path)`, at every level of every + derivation type (`bip32`, `bip44`, `bip49`, `bip54`, `bip74`, `bip84`, + `bip86`, `cip11`, `cip1852`, `zip32`, `slip10`), hardened or not, by + `parse_urn`, `parse_urn_strict`, `parse_nss`, `derivation_path::parse` + and `validate_derivation_path`. 1.1 accepted any value up to 2^32-1. A + BIP-32 child number keeps the hardened flag in its top bit (`n'` is child + number 2^31+n), so such an index aliased another key: a consumer + computing `hardened ? 0x80000000 | index : index` derives the key of + `m/44'/60'/0'/0/0` for `m/44'/60'/2147483648'/0/0`, and an unhardened + `m/44'/60'/0'/0/2147483648` asks for a hardened child number through the + public-key formula. No longer parse: any `dp` with a level of + `2147483648` to `4294967295`, with or without a hardened marker, e.g. + `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483648'/0/0` and + `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295`. The largest + index is `2147483647`. The `ct` component is not a path level and keeps + its 32-bit range. + ### Fixed - **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, @@ -27,6 +50,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `derivation_path` (its constructor, `charge()` and its layout); rebuild dependants. +### Tests + +- `tests/data/dp_conformance.txt`: a derivation-path conformance table + shared verbatim with go-mhda (`testdata/dp_conformance.txt`) and run by + both suites, standalone and inside a URN, so a path one implementation + refuses, the other refuses too. The boundary test and the fuzz seed that + pinned `4294967295` now sit at the new bound; 145 test cases total. + ## [1.1.0] — 2026-07-04 URN grammar 1.1, mirroring the go-mhda reference. The chain identity is now diff --git a/CMakeLists.txt b/CMakeLists.txt index a413a50..6dea226 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.14) project(mhda - VERSION 1.1.0 + VERSION 1.2.0 DESCRIPTION "MultiChain Hierarchical Deterministic Address (MHDA) — C++ port of go-mhda" LANGUAGES CXX ) diff --git a/README.md b/README.md index 5cdaea5..dcf2b10 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,17 @@ urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31 ## Status -- Version: **1.1.0** +- Version: **1.2.0** - Standard: **C++17**, no external runtime dependencies -- Tests: **139** unit + fuzz-equivalent stress cases (≈11 000 randomised +- Tests: **145** unit + fuzz-equivalent stress cases (≈11 000 randomised iterations), passing under `-fsanitize=address,undefined,leak` - Compilers verified: GCC 11.4 (Ubuntu 22.04), Clang 14 (when libstdc++ is available); the CI matrix runs Linux + macOS, Release + Debug - Warning policy: clean under `-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Werror` -- Binary stability is not yet guaranteed across minor versions; 1.1.0 - changes the URN grammar (see [CHANGELOG.md](./CHANGELOG.md)) +- Binary stability is not yet guaranteed across minor versions; 1.2.0 + refuses path indices of 2^31 or more and widens `charge_type` (see + [CHANGELOG.md](./CHANGELOG.md)) ## Building @@ -78,7 +79,7 @@ target_link_libraries(my_app PRIVATE mhda::mhda) include(FetchContent) FetchContent_Declare(mhda GIT_REPOSITORY https://github.com/censync/mhda.git - GIT_TAG v1.1.0 + GIT_TAG v1.2.0 ) FetchContent_MakeAvailable(mhda) target_link_libraries(my_app PRIVATE mhda::mhda) @@ -214,7 +215,7 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. ## Testing & validation -- 139 unit + fuzz-equivalent test cases. +- 145 unit + fuzz-equivalent test cases. - Fuzz harness runs ≈11 000 randomised mutations of the historical Go-fuzz seed corpus per execution (URN, NSS and derivation-path entry points). Contracts verified: no exception other than `parse_error`/`std::invalid_argument`, diff --git a/SPEC.md b/SPEC.md index 2c005d2..00492cc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -239,7 +239,7 @@ protocol messages. | bip74 | `m/74'/coin'/account'/charge/index[']` | 5 | sui-keys/src/key_derive.rs (Sui secp256r1) | | bip84 | `m/84'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki | | bip86 | `m/86'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki | -| slip10 | `m(/uint32['])+` (variable length) | 1+ | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | +| slip10 | `m(/index['])+` (variable length) | 1+ | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | | cip11 | `m/44'/118'/account'/charge_extra/index[']` | 5 | https://github.com/confio/cosmos-hd-key-derivation-spec | | cip1852 | `m/1852'/1815'/account'/role/index[']` | 5 | https://github.com/cardano-foundation/CIPs/blob/master/CIP-1852/ | | zip32 | `m/32'/133'/account'[/index[']]` | 3 or 4 | https://zips.z.cash/zip-0032 | @@ -248,6 +248,11 @@ Hardening markers in input: `'`, `H`, or `h` are all accepted and normalised to `'` in canonical output. The trailing `[']` on `index` denotes that the leaf level itself may be hardened. +Each level's index is 0..2^31-1 (at most `2147483647`); the hardened marker +is a separate flag, so `n'` is BIP-32 child number 2^31+n. An index of 2^31 +or more is refused at every level of every derivation type, hardened or not: +it collides with the hardened bit and would name the key of another path. + Leading zeros are accepted in a variable level and dropped in canonical output (`m/44'/060'/0'/0/0` is `m/44'/60'/0'/0/0`); the range applies to the value. A fixed level (the purpose, the fixed coin of `cip11`, `cip1852` and @@ -316,7 +321,8 @@ Performs structural validation only: whitespace around the whole URN is trimmed). - Chain ID is non-empty. - Derivation type, if present, is one of the registered constants. -- Derivation path, if present, matches the regex of its derivation type. +- Derivation path, if present, matches the regex of its derivation type, + and every level's index is below 2^31 (§3). - Algorithm, if present, is one of the registered constants. - Format, if present, is one of the registered constants. @@ -362,7 +368,7 @@ text. | `ErrInvalidChainKey` | `ChainFromKey` input is not the canonical identity form | | `ErrInvalidValue` | Free-form setter value with `:`/`?`/`#`/whitespace | | `ErrInvalidDerivationType` | `dt` value not registered | -| `ErrInvalidDerivationPath` | `dp` does not match the regex of `dt` | +| `ErrInvalidDerivationPath` | `dp` does not match `dt`, or a level index is >= 2^31 | | `ErrInvalidAlgorithm` | `aa` value not registered | | `ErrInvalidFormat` | `af` value not registered | | `ErrIncompatible` | Strict validation: triple not allowed for the network | diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index 387a5e5..57e46c8 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -20,8 +20,8 @@ using charge_type = std::uint32_t; constexpr charge_type charge_external = 0; constexpr charge_type charge_internal = 1; -// address_index represents a single level of a derivation path (an unsigned -// 32-bit index plus a hardening flag). +// address_index represents a single level of a derivation path: the index, +// below 2^31 in every parsed path, plus the separate hardening flag. struct address_index { std::uint32_t index = 0; bool is_hardened = false; diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 9dfffc7..c6857b9 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -28,7 +28,9 @@ bool is_hardening_marker(char c) noexcept { } // parse_segment decomposes a single path segment into (numeric_part, hardened -// flag). Returns false on empty/non-numeric input or 32-bit overflow. +// flag). Returns false on empty/non-numeric input or an index of 2^31 or +// more: the hardened flag is the top bit of a BIP-32 child number, so a larger +// index would name another level's key. Applies to every level of every type. bool parse_segment(std::string_view seg, raw_level& out) noexcept { bool hardened = false; if (!seg.empty() && is_hardening_marker(seg.back())) { @@ -36,7 +38,7 @@ bool parse_segment(std::string_view seg, raw_level& out) noexcept { seg.remove_suffix(1); } std::uint32_t value = 0; - if (!detail::parse_uint32_dec(seg, value)) return false; + if (!detail::parse_uint31_dec(seg, value)) return false; out.index = value; out.is_hardened = hardened; out.exact = seg.size() == 1 || seg[0] != '0'; diff --git a/src/detail.cpp b/src/detail.cpp index 3d59501..97a4ada 100644 --- a/src/detail.cpp +++ b/src/detail.cpp @@ -51,10 +51,10 @@ bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept { return true; } -bool parse_uint32_dec(std::string_view s, std::uint32_t& out) noexcept { +bool parse_uint31_dec(std::string_view s, std::uint32_t& out) noexcept { std::uint64_t value = 0; if (!parse_in_base(s, 10, value)) return false; - if (value > std::numeric_limits::max()) return false; + if (value >= (std::uint64_t{1} << 31)) return false; out = std::uint32_t(value); return true; } diff --git a/src/detail.hpp b/src/detail.hpp index 792b29a..b0474f6 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -92,9 +92,11 @@ inline std::vector split(std::string_view s, char sep) { // the coin-type ("ct") parsing paths. bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept; -// parse_uint32_dec parses a strictly decimal uint32_t. Used by per-level -// derivation-path parsing where Go uses base 10. -bool parse_uint32_dec(std::string_view s, std::uint32_t& out) noexcept; +// parse_uint31_dec parses a strictly decimal value below 2^31. Used by +// per-level derivation-path parsing: a BIP-32 child number keeps the hardened +// flag in its top bit (n' is 2^31+n), so a level index has 31 bits. Mirrors +// Go's strconv.ParseUint(s, 10, 31). +bool parse_uint31_dec(std::string_view s, std::uint32_t& out) noexcept; } // namespace detail } // namespace mhda diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f502f39..4363209 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,8 +11,12 @@ add_executable(mhda_tests test_edge.cpp test_boundary.cpp test_hardening.cpp + test_index_range.cpp ) target_link_libraries(mhda_tests PRIVATE mhda) target_include_directories(mhda_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# The derivation-path conformance table shared with go-mhda. +target_compile_definitions(mhda_tests PRIVATE + MHDA_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") add_test(NAME mhda_tests COMMAND mhda_tests) diff --git a/tests/data/dp_conformance.txt b/tests/data/dp_conformance.txt new file mode 100644 index 0000000..2056e69 --- /dev/null +++ b/tests/data/dp_conformance.txt @@ -0,0 +1,466 @@ +# Derivation-path conformance table, shared verbatim by go-mhda +# (testdata/dp_conformance.txt) and the C++ port (tests/data/dp_conformance.txt). +# Both test suites run every row; a path one implementation refuses, the other +# refuses too. Edit both copies together. +# +# Row forms: +# accept
+# refuse
+# A refused row fails with ErrInvalidDerivationPath / error_code::invalid_derivation_path. +# +# Each level's index is 0..2^31-1; the hardened marker is a separate flag. +# 2147483647 is the largest index, 2147483648 (2^31) and 4294967295 (2^32-1) +# are refused at every level of every type, hardened or not. Values at a +# level whose shape they do not fit (an unhardened account, a hardened +# charge, any value in a fixed level) are refused for shape. + +# bip32 m/0'/0/0 +# bip32 account (hardened only) +refuse bip32 m/2147483647/0/0 +accept bip32 m/2147483647'/0/0 m/2147483647'/0/0 +refuse bip32 m/2147483648/0/0 +refuse bip32 m/2147483648'/0/0 +refuse bip32 m/4294967295/0/0 +refuse bip32 m/4294967295'/0/0 +# bip32 charge (fixed 0|1) +refuse bip32 m/0'/2147483647/0 +refuse bip32 m/0'/2147483647'/0 +refuse bip32 m/0'/2147483648/0 +refuse bip32 m/0'/2147483648'/0 +refuse bip32 m/0'/4294967295/0 +refuse bip32 m/0'/4294967295'/0 +# bip32 index (hardening optional) +accept bip32 m/0'/0/2147483647 m/0'/0/2147483647 +accept bip32 m/0'/0/2147483647' m/0'/0/2147483647' +refuse bip32 m/0'/0/2147483648 +refuse bip32 m/0'/0/2147483648' +refuse bip32 m/0'/0/4294967295 +refuse bip32 m/0'/0/4294967295' + +# bip44 m/44'/60'/0'/0/0 +# bip44 purpose (fixed) +refuse bip44 m/2147483647/60'/0'/0/0 +refuse bip44 m/2147483647'/60'/0'/0/0 +refuse bip44 m/2147483648/60'/0'/0/0 +refuse bip44 m/2147483648'/60'/0'/0/0 +refuse bip44 m/4294967295/60'/0'/0/0 +refuse bip44 m/4294967295'/60'/0'/0/0 +# bip44 coin (hardened only) +refuse bip44 m/44'/2147483647/0'/0/0 +accept bip44 m/44'/2147483647'/0'/0/0 m/44'/2147483647'/0'/0/0 +refuse bip44 m/44'/2147483648/0'/0/0 +refuse bip44 m/44'/2147483648'/0'/0/0 +refuse bip44 m/44'/4294967295/0'/0/0 +refuse bip44 m/44'/4294967295'/0'/0/0 +# bip44 account (hardened only) +refuse bip44 m/44'/60'/2147483647/0/0 +accept bip44 m/44'/60'/2147483647'/0/0 m/44'/60'/2147483647'/0/0 +refuse bip44 m/44'/60'/2147483648/0/0 +refuse bip44 m/44'/60'/2147483648'/0/0 +refuse bip44 m/44'/60'/4294967295/0/0 +refuse bip44 m/44'/60'/4294967295'/0/0 +# bip44 charge (fixed 0|1) +refuse bip44 m/44'/60'/0'/2147483647/0 +refuse bip44 m/44'/60'/0'/2147483647'/0 +refuse bip44 m/44'/60'/0'/2147483648/0 +refuse bip44 m/44'/60'/0'/2147483648'/0 +refuse bip44 m/44'/60'/0'/4294967295/0 +refuse bip44 m/44'/60'/0'/4294967295'/0 +# bip44 index (hardening optional) +accept bip44 m/44'/60'/0'/0/2147483647 m/44'/60'/0'/0/2147483647 +accept bip44 m/44'/60'/0'/0/2147483647' m/44'/60'/0'/0/2147483647' +refuse bip44 m/44'/60'/0'/0/2147483648 +refuse bip44 m/44'/60'/0'/0/2147483648' +refuse bip44 m/44'/60'/0'/0/4294967295 +refuse bip44 m/44'/60'/0'/0/4294967295' + +# bip49 m/49'/0'/0'/0/0 +# bip49 purpose (fixed) +refuse bip49 m/2147483647/0'/0'/0/0 +refuse bip49 m/2147483647'/0'/0'/0/0 +refuse bip49 m/2147483648/0'/0'/0/0 +refuse bip49 m/2147483648'/0'/0'/0/0 +refuse bip49 m/4294967295/0'/0'/0/0 +refuse bip49 m/4294967295'/0'/0'/0/0 +# bip49 coin (hardened only) +refuse bip49 m/49'/2147483647/0'/0/0 +accept bip49 m/49'/2147483647'/0'/0/0 m/49'/2147483647'/0'/0/0 +refuse bip49 m/49'/2147483648/0'/0/0 +refuse bip49 m/49'/2147483648'/0'/0/0 +refuse bip49 m/49'/4294967295/0'/0/0 +refuse bip49 m/49'/4294967295'/0'/0/0 +# bip49 account (hardened only) +refuse bip49 m/49'/0'/2147483647/0/0 +accept bip49 m/49'/0'/2147483647'/0/0 m/49'/0'/2147483647'/0/0 +refuse bip49 m/49'/0'/2147483648/0/0 +refuse bip49 m/49'/0'/2147483648'/0/0 +refuse bip49 m/49'/0'/4294967295/0/0 +refuse bip49 m/49'/0'/4294967295'/0/0 +# bip49 charge (fixed 0|1) +refuse bip49 m/49'/0'/0'/2147483647/0 +refuse bip49 m/49'/0'/0'/2147483647'/0 +refuse bip49 m/49'/0'/0'/2147483648/0 +refuse bip49 m/49'/0'/0'/2147483648'/0 +refuse bip49 m/49'/0'/0'/4294967295/0 +refuse bip49 m/49'/0'/0'/4294967295'/0 +# bip49 index (hardening optional) +accept bip49 m/49'/0'/0'/0/2147483647 m/49'/0'/0'/0/2147483647 +accept bip49 m/49'/0'/0'/0/2147483647' m/49'/0'/0'/0/2147483647' +refuse bip49 m/49'/0'/0'/0/2147483648 +refuse bip49 m/49'/0'/0'/0/2147483648' +refuse bip49 m/49'/0'/0'/0/4294967295 +refuse bip49 m/49'/0'/0'/0/4294967295' + +# bip54 m/54'/784'/0'/0/0 +# bip54 purpose (fixed) +refuse bip54 m/2147483647/784'/0'/0/0 +refuse bip54 m/2147483647'/784'/0'/0/0 +refuse bip54 m/2147483648/784'/0'/0/0 +refuse bip54 m/2147483648'/784'/0'/0/0 +refuse bip54 m/4294967295/784'/0'/0/0 +refuse bip54 m/4294967295'/784'/0'/0/0 +# bip54 coin (hardened only) +refuse bip54 m/54'/2147483647/0'/0/0 +accept bip54 m/54'/2147483647'/0'/0/0 m/54'/2147483647'/0'/0/0 +refuse bip54 m/54'/2147483648/0'/0/0 +refuse bip54 m/54'/2147483648'/0'/0/0 +refuse bip54 m/54'/4294967295/0'/0/0 +refuse bip54 m/54'/4294967295'/0'/0/0 +# bip54 account (hardened only) +refuse bip54 m/54'/784'/2147483647/0/0 +accept bip54 m/54'/784'/2147483647'/0/0 m/54'/784'/2147483647'/0/0 +refuse bip54 m/54'/784'/2147483648/0/0 +refuse bip54 m/54'/784'/2147483648'/0/0 +refuse bip54 m/54'/784'/4294967295/0/0 +refuse bip54 m/54'/784'/4294967295'/0/0 +# bip54 charge (fixed 0|1) +refuse bip54 m/54'/784'/0'/2147483647/0 +refuse bip54 m/54'/784'/0'/2147483647'/0 +refuse bip54 m/54'/784'/0'/2147483648/0 +refuse bip54 m/54'/784'/0'/2147483648'/0 +refuse bip54 m/54'/784'/0'/4294967295/0 +refuse bip54 m/54'/784'/0'/4294967295'/0 +# bip54 index (hardening optional) +accept bip54 m/54'/784'/0'/0/2147483647 m/54'/784'/0'/0/2147483647 +accept bip54 m/54'/784'/0'/0/2147483647' m/54'/784'/0'/0/2147483647' +refuse bip54 m/54'/784'/0'/0/2147483648 +refuse bip54 m/54'/784'/0'/0/2147483648' +refuse bip54 m/54'/784'/0'/0/4294967295 +refuse bip54 m/54'/784'/0'/0/4294967295' + +# bip74 m/74'/784'/0'/0/0 +# bip74 purpose (fixed) +refuse bip74 m/2147483647/784'/0'/0/0 +refuse bip74 m/2147483647'/784'/0'/0/0 +refuse bip74 m/2147483648/784'/0'/0/0 +refuse bip74 m/2147483648'/784'/0'/0/0 +refuse bip74 m/4294967295/784'/0'/0/0 +refuse bip74 m/4294967295'/784'/0'/0/0 +# bip74 coin (hardened only) +refuse bip74 m/74'/2147483647/0'/0/0 +accept bip74 m/74'/2147483647'/0'/0/0 m/74'/2147483647'/0'/0/0 +refuse bip74 m/74'/2147483648/0'/0/0 +refuse bip74 m/74'/2147483648'/0'/0/0 +refuse bip74 m/74'/4294967295/0'/0/0 +refuse bip74 m/74'/4294967295'/0'/0/0 +# bip74 account (hardened only) +refuse bip74 m/74'/784'/2147483647/0/0 +accept bip74 m/74'/784'/2147483647'/0/0 m/74'/784'/2147483647'/0/0 +refuse bip74 m/74'/784'/2147483648/0/0 +refuse bip74 m/74'/784'/2147483648'/0/0 +refuse bip74 m/74'/784'/4294967295/0/0 +refuse bip74 m/74'/784'/4294967295'/0/0 +# bip74 charge (fixed 0|1) +refuse bip74 m/74'/784'/0'/2147483647/0 +refuse bip74 m/74'/784'/0'/2147483647'/0 +refuse bip74 m/74'/784'/0'/2147483648/0 +refuse bip74 m/74'/784'/0'/2147483648'/0 +refuse bip74 m/74'/784'/0'/4294967295/0 +refuse bip74 m/74'/784'/0'/4294967295'/0 +# bip74 index (hardening optional) +accept bip74 m/74'/784'/0'/0/2147483647 m/74'/784'/0'/0/2147483647 +accept bip74 m/74'/784'/0'/0/2147483647' m/74'/784'/0'/0/2147483647' +refuse bip74 m/74'/784'/0'/0/2147483648 +refuse bip74 m/74'/784'/0'/0/2147483648' +refuse bip74 m/74'/784'/0'/0/4294967295 +refuse bip74 m/74'/784'/0'/0/4294967295' + +# bip84 m/84'/0'/0'/0/0 +# bip84 purpose (fixed) +refuse bip84 m/2147483647/0'/0'/0/0 +refuse bip84 m/2147483647'/0'/0'/0/0 +refuse bip84 m/2147483648/0'/0'/0/0 +refuse bip84 m/2147483648'/0'/0'/0/0 +refuse bip84 m/4294967295/0'/0'/0/0 +refuse bip84 m/4294967295'/0'/0'/0/0 +# bip84 coin (hardened only) +refuse bip84 m/84'/2147483647/0'/0/0 +accept bip84 m/84'/2147483647'/0'/0/0 m/84'/2147483647'/0'/0/0 +refuse bip84 m/84'/2147483648/0'/0/0 +refuse bip84 m/84'/2147483648'/0'/0/0 +refuse bip84 m/84'/4294967295/0'/0/0 +refuse bip84 m/84'/4294967295'/0'/0/0 +# bip84 account (hardened only) +refuse bip84 m/84'/0'/2147483647/0/0 +accept bip84 m/84'/0'/2147483647'/0/0 m/84'/0'/2147483647'/0/0 +refuse bip84 m/84'/0'/2147483648/0/0 +refuse bip84 m/84'/0'/2147483648'/0/0 +refuse bip84 m/84'/0'/4294967295/0/0 +refuse bip84 m/84'/0'/4294967295'/0/0 +# bip84 charge (fixed 0|1) +refuse bip84 m/84'/0'/0'/2147483647/0 +refuse bip84 m/84'/0'/0'/2147483647'/0 +refuse bip84 m/84'/0'/0'/2147483648/0 +refuse bip84 m/84'/0'/0'/2147483648'/0 +refuse bip84 m/84'/0'/0'/4294967295/0 +refuse bip84 m/84'/0'/0'/4294967295'/0 +# bip84 index (hardening optional) +accept bip84 m/84'/0'/0'/0/2147483647 m/84'/0'/0'/0/2147483647 +accept bip84 m/84'/0'/0'/0/2147483647' m/84'/0'/0'/0/2147483647' +refuse bip84 m/84'/0'/0'/0/2147483648 +refuse bip84 m/84'/0'/0'/0/2147483648' +refuse bip84 m/84'/0'/0'/0/4294967295 +refuse bip84 m/84'/0'/0'/0/4294967295' + +# bip86 m/86'/0'/0'/0/0 +# bip86 purpose (fixed) +refuse bip86 m/2147483647/0'/0'/0/0 +refuse bip86 m/2147483647'/0'/0'/0/0 +refuse bip86 m/2147483648/0'/0'/0/0 +refuse bip86 m/2147483648'/0'/0'/0/0 +refuse bip86 m/4294967295/0'/0'/0/0 +refuse bip86 m/4294967295'/0'/0'/0/0 +# bip86 coin (hardened only) +refuse bip86 m/86'/2147483647/0'/0/0 +accept bip86 m/86'/2147483647'/0'/0/0 m/86'/2147483647'/0'/0/0 +refuse bip86 m/86'/2147483648/0'/0/0 +refuse bip86 m/86'/2147483648'/0'/0/0 +refuse bip86 m/86'/4294967295/0'/0/0 +refuse bip86 m/86'/4294967295'/0'/0/0 +# bip86 account (hardened only) +refuse bip86 m/86'/0'/2147483647/0/0 +accept bip86 m/86'/0'/2147483647'/0/0 m/86'/0'/2147483647'/0/0 +refuse bip86 m/86'/0'/2147483648/0/0 +refuse bip86 m/86'/0'/2147483648'/0/0 +refuse bip86 m/86'/0'/4294967295/0/0 +refuse bip86 m/86'/0'/4294967295'/0/0 +# bip86 charge (fixed 0|1) +refuse bip86 m/86'/0'/0'/2147483647/0 +refuse bip86 m/86'/0'/0'/2147483647'/0 +refuse bip86 m/86'/0'/0'/2147483648/0 +refuse bip86 m/86'/0'/0'/2147483648'/0 +refuse bip86 m/86'/0'/0'/4294967295/0 +refuse bip86 m/86'/0'/0'/4294967295'/0 +# bip86 index (hardening optional) +accept bip86 m/86'/0'/0'/0/2147483647 m/86'/0'/0'/0/2147483647 +accept bip86 m/86'/0'/0'/0/2147483647' m/86'/0'/0'/0/2147483647' +refuse bip86 m/86'/0'/0'/0/2147483648 +refuse bip86 m/86'/0'/0'/0/2147483648' +refuse bip86 m/86'/0'/0'/0/4294967295 +refuse bip86 m/86'/0'/0'/0/4294967295' + +# cip11 m/44'/118'/0'/0/0 +# cip11 purpose (fixed) +refuse cip11 m/2147483647/118'/0'/0/0 +refuse cip11 m/2147483647'/118'/0'/0/0 +refuse cip11 m/2147483648/118'/0'/0/0 +refuse cip11 m/2147483648'/118'/0'/0/0 +refuse cip11 m/4294967295/118'/0'/0/0 +refuse cip11 m/4294967295'/118'/0'/0/0 +# cip11 coin (fixed) +refuse cip11 m/44'/2147483647/0'/0/0 +refuse cip11 m/44'/2147483647'/0'/0/0 +refuse cip11 m/44'/2147483648/0'/0/0 +refuse cip11 m/44'/2147483648'/0'/0/0 +refuse cip11 m/44'/4294967295/0'/0/0 +refuse cip11 m/44'/4294967295'/0'/0/0 +# cip11 account (hardened only) +refuse cip11 m/44'/118'/2147483647/0/0 +accept cip11 m/44'/118'/2147483647'/0/0 m/44'/118'/2147483647'/0/0 +refuse cip11 m/44'/118'/2147483648/0/0 +refuse cip11 m/44'/118'/2147483648'/0/0 +refuse cip11 m/44'/118'/4294967295/0/0 +refuse cip11 m/44'/118'/4294967295'/0/0 +# cip11 charge (soft only) +accept cip11 m/44'/118'/0'/2147483647/0 m/44'/118'/0'/2147483647/0 +refuse cip11 m/44'/118'/0'/2147483647'/0 +refuse cip11 m/44'/118'/0'/2147483648/0 +refuse cip11 m/44'/118'/0'/2147483648'/0 +refuse cip11 m/44'/118'/0'/4294967295/0 +refuse cip11 m/44'/118'/0'/4294967295'/0 +# cip11 index (hardening optional) +accept cip11 m/44'/118'/0'/0/2147483647 m/44'/118'/0'/0/2147483647 +accept cip11 m/44'/118'/0'/0/2147483647' m/44'/118'/0'/0/2147483647' +refuse cip11 m/44'/118'/0'/0/2147483648 +refuse cip11 m/44'/118'/0'/0/2147483648' +refuse cip11 m/44'/118'/0'/0/4294967295 +refuse cip11 m/44'/118'/0'/0/4294967295' + +# cip1852 m/1852'/1815'/0'/0/0 +# cip1852 purpose (fixed) +refuse cip1852 m/2147483647/1815'/0'/0/0 +refuse cip1852 m/2147483647'/1815'/0'/0/0 +refuse cip1852 m/2147483648/1815'/0'/0/0 +refuse cip1852 m/2147483648'/1815'/0'/0/0 +refuse cip1852 m/4294967295/1815'/0'/0/0 +refuse cip1852 m/4294967295'/1815'/0'/0/0 +# cip1852 coin (fixed) +refuse cip1852 m/1852'/2147483647/0'/0/0 +refuse cip1852 m/1852'/2147483647'/0'/0/0 +refuse cip1852 m/1852'/2147483648/0'/0/0 +refuse cip1852 m/1852'/2147483648'/0'/0/0 +refuse cip1852 m/1852'/4294967295/0'/0/0 +refuse cip1852 m/1852'/4294967295'/0'/0/0 +# cip1852 account (hardened only) +refuse cip1852 m/1852'/1815'/2147483647/0/0 +accept cip1852 m/1852'/1815'/2147483647'/0/0 m/1852'/1815'/2147483647'/0/0 +refuse cip1852 m/1852'/1815'/2147483648/0/0 +refuse cip1852 m/1852'/1815'/2147483648'/0/0 +refuse cip1852 m/1852'/1815'/4294967295/0/0 +refuse cip1852 m/1852'/1815'/4294967295'/0/0 +# cip1852 role (soft only) +accept cip1852 m/1852'/1815'/0'/2147483647/0 m/1852'/1815'/0'/2147483647/0 +refuse cip1852 m/1852'/1815'/0'/2147483647'/0 +refuse cip1852 m/1852'/1815'/0'/2147483648/0 +refuse cip1852 m/1852'/1815'/0'/2147483648'/0 +refuse cip1852 m/1852'/1815'/0'/4294967295/0 +refuse cip1852 m/1852'/1815'/0'/4294967295'/0 +# cip1852 index (hardening optional) +accept cip1852 m/1852'/1815'/0'/0/2147483647 m/1852'/1815'/0'/0/2147483647 +accept cip1852 m/1852'/1815'/0'/0/2147483647' m/1852'/1815'/0'/0/2147483647' +refuse cip1852 m/1852'/1815'/0'/0/2147483648 +refuse cip1852 m/1852'/1815'/0'/0/2147483648' +refuse cip1852 m/1852'/1815'/0'/0/4294967295 +refuse cip1852 m/1852'/1815'/0'/0/4294967295' + +# zip32 m/32'/133'/0' +# zip32 purpose (fixed) +refuse zip32 m/2147483647/133'/0' +refuse zip32 m/2147483647'/133'/0' +refuse zip32 m/2147483648/133'/0' +refuse zip32 m/2147483648'/133'/0' +refuse zip32 m/4294967295/133'/0' +refuse zip32 m/4294967295'/133'/0' +# zip32 coin (fixed) +refuse zip32 m/32'/2147483647/0' +refuse zip32 m/32'/2147483647'/0' +refuse zip32 m/32'/2147483648/0' +refuse zip32 m/32'/2147483648'/0' +refuse zip32 m/32'/4294967295/0' +refuse zip32 m/32'/4294967295'/0' +# zip32 account (hardened only) +refuse zip32 m/32'/133'/2147483647 +accept zip32 m/32'/133'/2147483647' m/32'/133'/2147483647' +refuse zip32 m/32'/133'/2147483648 +refuse zip32 m/32'/133'/2147483648' +refuse zip32 m/32'/133'/4294967295 +refuse zip32 m/32'/133'/4294967295' + +# zip32 m/32'/133'/0'/0 +# zip32 purpose (fixed) +refuse zip32 m/2147483647/133'/0'/0 +refuse zip32 m/2147483647'/133'/0'/0 +refuse zip32 m/2147483648/133'/0'/0 +refuse zip32 m/2147483648'/133'/0'/0 +refuse zip32 m/4294967295/133'/0'/0 +refuse zip32 m/4294967295'/133'/0'/0 +# zip32 coin (fixed) +refuse zip32 m/32'/2147483647/0'/0 +refuse zip32 m/32'/2147483647'/0'/0 +refuse zip32 m/32'/2147483648/0'/0 +refuse zip32 m/32'/2147483648'/0'/0 +refuse zip32 m/32'/4294967295/0'/0 +refuse zip32 m/32'/4294967295'/0'/0 +# zip32 account (hardened only) +refuse zip32 m/32'/133'/2147483647/0 +accept zip32 m/32'/133'/2147483647'/0 m/32'/133'/2147483647'/0 +refuse zip32 m/32'/133'/2147483648/0 +refuse zip32 m/32'/133'/2147483648'/0 +refuse zip32 m/32'/133'/4294967295/0 +refuse zip32 m/32'/133'/4294967295'/0 +# zip32 index (hardening optional) +accept zip32 m/32'/133'/0'/2147483647 m/32'/133'/0'/2147483647 +accept zip32 m/32'/133'/0'/2147483647' m/32'/133'/0'/2147483647' +refuse zip32 m/32'/133'/0'/2147483648 +refuse zip32 m/32'/133'/0'/2147483648' +refuse zip32 m/32'/133'/0'/4294967295 +refuse zip32 m/32'/133'/0'/4294967295' + +# slip10 m/44'/501'/0'/0' +# slip10 level 0 (hardening optional) +accept slip10 m/2147483647/501'/0'/0' m/2147483647/501'/0'/0' +accept slip10 m/2147483647'/501'/0'/0' m/2147483647'/501'/0'/0' +refuse slip10 m/2147483648/501'/0'/0' +refuse slip10 m/2147483648'/501'/0'/0' +refuse slip10 m/4294967295/501'/0'/0' +refuse slip10 m/4294967295'/501'/0'/0' +# slip10 level 1 (hardening optional) +accept slip10 m/44'/2147483647/0'/0' m/44'/2147483647/0'/0' +accept slip10 m/44'/2147483647'/0'/0' m/44'/2147483647'/0'/0' +refuse slip10 m/44'/2147483648/0'/0' +refuse slip10 m/44'/2147483648'/0'/0' +refuse slip10 m/44'/4294967295/0'/0' +refuse slip10 m/44'/4294967295'/0'/0' +# slip10 level 2 (hardening optional) +accept slip10 m/44'/501'/2147483647/0' m/44'/501'/2147483647/0' +accept slip10 m/44'/501'/2147483647'/0' m/44'/501'/2147483647'/0' +refuse slip10 m/44'/501'/2147483648/0' +refuse slip10 m/44'/501'/2147483648'/0' +refuse slip10 m/44'/501'/4294967295/0' +refuse slip10 m/44'/501'/4294967295'/0' +# slip10 level 3 (hardening optional) +accept slip10 m/44'/501'/0'/2147483647 m/44'/501'/0'/2147483647 +accept slip10 m/44'/501'/0'/2147483647' m/44'/501'/0'/2147483647' +refuse slip10 m/44'/501'/0'/2147483648 +refuse slip10 m/44'/501'/0'/2147483648' +refuse slip10 m/44'/501'/0'/4294967295 +refuse slip10 m/44'/501'/0'/4294967295' + +# slip10 m/0 +# slip10 level 0 (hardening optional) +accept slip10 m/2147483647 m/2147483647 +accept slip10 m/2147483647' m/2147483647' +refuse slip10 m/2147483648 +refuse slip10 m/2147483648' +refuse slip10 m/4294967295 +refuse slip10 m/4294967295' + +# Hardened markers h and H count like ' and are emitted as '. +accept bip44 m/44h/60H/2147483647h/0/2147483647H m/44'/60'/2147483647'/0/2147483647' +refuse bip44 m/44'/60'/2147483648h/0/0 +refuse bip44 m/44'/60'/0'/0/2147483648H +accept slip10 m/2147483647h/2147483647H/2147483647 m/2147483647'/2147483647'/2147483647 +refuse slip10 m/2147483647h/2147483648H + +# Beyond 32 bits. +refuse bip44 m/44'/60'/0'/0/4294967296 +refuse bip44 m/44'/60'/0'/0/18446744073709551616 +refuse slip10 m/99999999999999999999 +refuse slip10 m/0/99999999999999999999' + +# Leading zeros. A variable level accepts them and the canonical form drops +# them; the range check applies to the value. A fixed level (purpose, the +# fixed coin of cip11/cip1852/zip32, the 0|1 charge of bip32 and the +# BIP-44 family) must be spelled exactly as in its template. +accept bip44 m/44'/060'/0'/0/0 m/44'/60'/0'/0/0 +accept bip44 m/44'/60'/00'/0/00 m/44'/60'/0'/0/0 +accept bip44 m/44'/60'/0'/0/02147483647 m/44'/60'/0'/0/2147483647 +accept bip44 m/44'/60'/0'/0/000000000000000000002147483647' m/44'/60'/0'/0/2147483647' +refuse bip44 m/44'/60'/0'/0/02147483648 +refuse bip44 m/44'/60'/000000000000000000002147483648'/0/0 +refuse bip44 m/044'/60'/0'/0/0 +refuse bip44 m/44'/60'/0'/00/0 +refuse bip44 m/44'/60'/0'/01/0 +refuse bip84 m/084'/0'/0'/0/0 +refuse bip32 m/0'/00/0 +accept bip32 m/00'/1/007' m/0'/1/7' +refuse cip11 m/044'/118'/0'/0/0 +refuse cip11 m/44'/0118'/0'/0/0 +accept cip11 m/44'/118'/0'/09/0 m/44'/118'/0'/9/0 +refuse cip1852 m/01852'/1815'/0'/0/0 +refuse cip1852 m/1852'/01815'/0'/0/0 +accept cip1852 m/1852'/1815'/0'/02/0 m/1852'/1815'/0'/2/0 +refuse zip32 m/032'/133'/0' +refuse zip32 m/32'/0133'/0' +accept zip32 m/32'/133'/00'/00' m/32'/133'/0'/0' +accept slip10 m/044'/0501'/00' m/44'/501'/0' diff --git a/tests/test_boundary.cpp b/tests/test_boundary.cpp index d6835d1..4f7c774 100644 --- a/tests/test_boundary.cpp +++ b/tests/test_boundary.cpp @@ -61,18 +61,22 @@ TEST_CASE("coin_type rejects leading plus") { error_code::invalid_coin_type); } -TEST_CASE("derivation_path leaf accepts uint32 max") { +TEST_CASE("derivation_path leaf accepts 2^31-1") { auto a = parse_urn( - "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967295"); - EXPECT_EQ(a.path()->index().index, std::numeric_limits::max()); + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/2147483647"); + EXPECT_EQ(a.path()->index().index, std::uint32_t{0x7FFFFFFF}); EXPECT_EQ(a.str(), - std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967295"}); + std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/2147483647"}); } -TEST_CASE("derivation_path leaf rejects uint32 max + 1") { - EXPECT_THROW_CODE(parse_urn( - "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/4294967296"), - error_code::invalid_derivation_path); +TEST_CASE("derivation_path leaf rejects 2^31 and above") { + // A level index is 31 bits: 2^31 and above collide with the hardened bit. + for (const char* leaf : {"2147483648", "2147483648'", "4294967295", "4294967295'", + "4294967296"}) { + EXPECT_THROW_CODE(parse_urn( + std::string{"urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/0'/0/"} + leaf), + error_code::invalid_derivation_path); + } } TEST_CASE("BIP-44 charge field rejects values outside {0,1}") { diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp index 6f3227b..cd42605 100644 --- a/tests/test_fuzz.cpp +++ b/tests/test_fuzz.cpp @@ -56,7 +56,7 @@ const std::vector kSeedURNs = { const std::vector> kSeedPaths = { {"bip32", "m/0'/0/0"}, - {"bip32", "m/2147483647'/1/4294967295'"}, + {"bip32", "m/2147483647'/1/2147483647'"}, // largest index, 2^31-1 {"bip44", "m/44'/60'/0'/0/0"}, {"bip44", "m/44'/0'/0'/0/0'"}, {"bip49", "m/49'/0'/0'/0/0"}, @@ -81,6 +81,8 @@ const std::vector> kSeedPaths = { {"slip10", "m"}, {"slip10", "m/"}, {"slip10", "m/99999999999999999999"}, + {"bip32", "m/2147483648'/1/0"}, // index 2^31 + {"bip44", "m/44'/60'/0'/0/4294967295"}, // index 2^32-1 {"unknown", "m/0/0/0"}, }; @@ -208,7 +210,14 @@ TEST_CASE("FuzzDerivationPath: no panic, idempotent on success") { + " twice=" + dp2.str() + " input dt=" + dt_str + " path=" + path}); } - (void)dp.levels(); + // No accepted level may carry an index of 2^31 or more. + for (const auto& lvl : dp.levels()) { + if (lvl.index > 0x7FFFFFFFu) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"level above 2^31-1: input dt="} + dt_str + + " path=" + path}); + } + } } catch (const parse_error&) { // Rejection is fine. } catch (const std::invalid_argument&) { diff --git a/tests/test_index_range.cpp b/tests/test_index_range.cpp new file mode 100644 index 0000000..cd484d7 --- /dev/null +++ b/tests/test_index_range.cpp @@ -0,0 +1,142 @@ +// Derivation-path level index range, mirroring index_range_test.go of the Go +// reference: a level index of 2^31 or more is refused at every level of every +// derivation type, and the conformance table shared with go-mhda +// (tests/data/dp_conformance.txt) runs through both the standalone and the +// URN parser. + +#include +#include +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +constexpr std::uint32_t kMaxLevelIndex = 0x7FFFFFFFu; + +} // namespace + +// An index of 2^31 or more collides with the hardened bit of a BIP-32 child +// number: m/44'/60'/2147483648'/0/0 would derive the key of m/44'/60'/0'/0/0, +// and an unhardened 2147483648 would ask for a hardened child through the +// public-key formula. +TEST_CASE("level index of 2^31 or more is refused") { + const std::vector urns = { + "urn:mhda:nt:evm:ci:1:ct:60:dt:bip44:dp:m/44'/60'/2147483648'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483648'/0/0", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/2147483648", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295", + "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/2147483708'/0'/0/0", + "urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/2147483648'/0'", + }; + for (const auto& urn : urns) { + EXPECT_THROW_CODE(parse_urn(urn), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::invalid_derivation_path); + } + + const std::string max = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/2147483647'/0/2147483647'"; + auto a = parse_urn_strict(max); + EXPECT_EQ(a.str(), max); + EXPECT_EQ(a.path()->account(), kMaxLevelIndex); + EXPECT_EQ(a.path()->index(), (address_index{kMaxLevelIndex, true})); +} + +// The ct component is SLIP-44 metadata, not a path level, and keeps the full +// 32-bit range. +TEST_CASE("ct is not a path level and keeps 32 bits") { + const std::vector> rows = { + {"urn:mhda:nt:evm:ci:1:ct:2147483648:dt:bip44:dp:m/44'/60'/0'/0/0", 2147483648u}, + {"urn:mhda:nt:evm:ci:1:ct:4294967295:dt:bip44:dp:m/44'/60'/0'/0/0", 4294967295u}, + }; + for (const auto& r : rows) { + auto a = parse_urn(r.first); + EXPECT_TRUE(a.get_chain().coin().has_value()); + EXPECT_EQ(*a.get_chain().coin(), r.second); + EXPECT_EQ(a.str(), r.first); + } +} + +// Runs the derivation-path table shared verbatim with go-mhda +// (testdata/dp_conformance.txt there). Every row is parsed standalone, through +// validate_derivation_path and inside a URN; all three must agree with the row. +TEST_CASE("derivation-path conformance table shared with go-mhda") { + std::ifstream in(MHDA_TEST_DATA_DIR "/dp_conformance.txt"); + if (!in) { + mhda_failures.push_back({__FILE__, __LINE__, + "cannot open " MHDA_TEST_DATA_DIR "/dp_conformance.txt"}); + return; + } + + int rows = 0; + std::string text; + for (int line = 1; std::getline(in, text); ++line) { + if (text.empty() || text[0] == '#') continue; + std::istringstream fields(text); + std::string verdict, dt_str, path, canonical, extra; + fields >> verdict >> dt_str >> path >> canonical >> extra; + const bool accept = verdict == "accept"; + if (!extra.empty() || (accept && canonical.empty()) || + (!accept && (verdict != "refuse" || !canonical.empty()))) { + mhda_failures.push_back({__FILE__, __LINE__, + "dp_conformance.txt:" + std::to_string(line) + ": malformed row " + text}); + return; + } + ++rows; + const std::string where = "dp_conformance.txt:" + std::to_string(line) + ": " + text; + const derivation_type dt{dt_str}; + + if (validate_derivation_path(dt, path) != accept) { + mhda_failures.push_back({__FILE__, __LINE__, "validate_derivation_path disagrees, " + where}); + } + + const std::string urn = "urn:mhda:nt:evm:ci:1:dt:" + dt_str + ":dp:" + path; + try { + auto dp = derivation_path::parse(dt, path); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "parse accepted, " + where}); + } else { + if (dp.str() != canonical) { + mhda_failures.push_back({__FILE__, __LINE__, + "parse str() = " + dp.str() + ", " + where}); + } + for (const auto& lvl : dp.levels()) { + if (lvl.index > kMaxLevelIndex) { + mhda_failures.push_back({__FILE__, __LINE__, + "level above 2^31-1, " + where}); + } + } + } + } catch (const parse_error& e) { + if (accept || e.code() != error_code::invalid_derivation_path) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"parse threw "} + e.what() + ", " + where}); + } + } + + try { + auto a = parse_urn(urn); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "parse_urn accepted, " + where}); + } else if (a.str() != "urn:mhda:nt:evm:ci:1:dt:" + dt_str + ":dp:" + canonical) { + mhda_failures.push_back({__FILE__, __LINE__, + "parse_urn str() = " + a.str() + ", " + where}); + } + } catch (const parse_error& e) { + if (accept || e.code() != error_code::invalid_derivation_path) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"parse_urn threw "} + e.what() + ", " + where}); + } + } + } + if (rows < 300) { + mhda_failures.push_back({__FILE__, __LINE__, + "dp_conformance.txt: " + std::to_string(rows) + " rows, the table is truncated"}); + } +} From 0185634a9a7840eedffb547f8056295708e927a8 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:38:27 +0300 Subject: [PATCH 04/21] Make mhda usable from a consumer's static initialisers and destructors The named constants (network_type::bitcoin, algorithm::secp256k1, format::hex, derivation_type::root, ...) were namespace-scope objects defined in the library's .cpp files, built at start-up in an unspecified order relative to a consumer's globals, and the lookup tables copied them on first use. A consumer global that ran first saw empty values and left the tables wrong for the whole process: parse_urn_strict then refused every network as unknown, even from main(). A global destructor that parsed at exit read tables already destroyed and crashed. The constants are now inline variables defined in the public headers: every translation unit that names one includes the definition, so it is initialised before any global defined after the include. The lookup tables (network index, algorithm/format/derivation registries, component set, compatibility matrix) are allocated once and never destroyed. tests/test_static_init.cpp holds a global that parses during static initialisation and one that parses in its destructor; before the change the suite crashed. --- CHANGELOG.md | 13 +++++++ README.md | 5 ++- include/mhda/algorithm.hpp | 12 ++++++ include/mhda/derivation_type.hpp | 17 ++++++++ include/mhda/format.hpp | 18 +++++++++ include/mhda/network_type.hpp | 23 +++++++++++ src/algorithm.cpp | 14 ++----- src/compatibility.cpp | 8 ++-- src/derivation_type.cpp | 19 ++------- src/format.cpp | 20 ++-------- src/network_type.cpp | 24 ++---------- src/nss_parser.cpp | 6 ++- tests/CMakeLists.txt | 1 + tests/test_static_init.cpp | 66 ++++++++++++++++++++++++++++++++ 14 files changed, 179 insertions(+), 67 deletions(-) create mode 100644 tests/test_static_init.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 806ff49..7d781e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,19 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. ### Fixed +- **Static initialisation and destruction order.** The named constants + (`network_type::bitcoin`, `algorithm::secp256k1`, `format::hex`, + `derivation_type::root`, ...) were defined in the library's `.cpp` files + and built at start-up in an unspecified order relative to the consumer's + globals, and the lookup tables copied them on first use. A consumer + global that used one (a built-in chain table, a URN validated up front) + could run first: it saw empty values and left the tables wrong for the + whole process (`parse_urn_strict` then refused every network as + unknown). A global destructor that parsed at exit read destroyed tables + and crashed. The constants are now inline variables defined in the + public headers, so they are initialised before any global defined after + the include, and the lookup tables are allocated once and never + destroyed. - **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, the fixed coin of `cip11` / `cip1852` / `zip32` and the `0`/`1` charge of `bip32` and the BIP-44 family are literals in the Go reference's grammar; diff --git a/README.md b/README.md index dcf2b10..878b934 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,10 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. index, algorithm/format/derivation registries) are populated lazily inside `static const` function-locals (Magic Statics — guaranteed thread-safe by C++11 §6.7.4) and are read-only thereafter; concurrent reads are safe and - introduce no locking. + introduce no locking. The tables are allocated once and never destroyed, + and the named constants (`network_type::bitcoin`, `derivation_type::root`, + ...) are inline variables defined in the public headers, so the library + also works from a consumer's global constructors and destructors. - No `std::mutex`, `std::atomic`, recursive locks or condition variables are used anywhere in the library — deadlock by construction is impossible. diff --git a/include/mhda/algorithm.hpp b/include/mhda/algorithm.hpp index b729cee..6c607de 100644 --- a/include/mhda/algorithm.hpp +++ b/include/mhda/algorithm.hpp @@ -33,6 +33,18 @@ class algorithm { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const algorithm algorithm::secp256k1 {"secp256k1"}; +inline const algorithm algorithm::ed25519 {"ed25519"}; +inline const algorithm algorithm::sr25519 {"sr25519"}; +inline const algorithm algorithm::secp256r1 {"secp256r1"}; +inline const algorithm algorithm::secp384r1 {"secp384r1"}; +inline const algorithm algorithm::secp521r1 {"secp521r1"}; +inline const algorithm algorithm::prime256v1 {"prime256v1"}; + // algorithm_from_string parses a string into an algorithm. Lookup is // case-insensitive; surrounding whitespace is stripped. Returns an empty // optional for unknown values. diff --git a/include/mhda/derivation_type.hpp b/include/mhda/derivation_type.hpp index 1249c77..f06a8d8 100644 --- a/include/mhda/derivation_type.hpp +++ b/include/mhda/derivation_type.hpp @@ -38,6 +38,23 @@ class derivation_type { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const derivation_type derivation_type::root {"root"}; +inline const derivation_type derivation_type::bip32 {"bip32"}; +inline const derivation_type derivation_type::bip44 {"bip44"}; +inline const derivation_type derivation_type::bip49 {"bip49"}; +inline const derivation_type derivation_type::bip54 {"bip54"}; +inline const derivation_type derivation_type::bip74 {"bip74"}; +inline const derivation_type derivation_type::bip84 {"bip84"}; +inline const derivation_type derivation_type::bip86 {"bip86"}; +inline const derivation_type derivation_type::slip10 {"slip10"}; +inline const derivation_type derivation_type::cip1852 {"cip1852"}; +inline const derivation_type derivation_type::cip11 {"cip11"}; +inline const derivation_type derivation_type::zip32 {"zip32"}; + // derivation_type_from_string parses a string into a derivation_type. Lookup // is case-insensitive; surrounding whitespace is stripped. Throws parse_error // with code error_code::invalid_derivation_type for unknown values. diff --git a/include/mhda/format.hpp b/include/mhda/format.hpp index fae75b7..41c7eae 100644 --- a/include/mhda/format.hpp +++ b/include/mhda/format.hpp @@ -39,6 +39,24 @@ class format { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +inline const format format::hex {"hex"}; +inline const format format::p2pkh {"p2pkh"}; +inline const format format::p2sh {"p2sh"}; +inline const format format::p2wpkh {"p2wpkh"}; +inline const format format::p2wsh {"p2wsh"}; +inline const format format::p2tr {"p2tr"}; +inline const format format::bech32 {"bech32"}; +inline const format format::bech32m {"bech32m"}; +inline const format format::base58 {"base58"}; +inline const format format::base32 {"base32"}; +inline const format format::strkey {"strkey"}; +inline const format format::base64url {"base64url"}; +inline const format format::ss58 {"ss58"}; + // format_from_string parses a string into a format. Lookup is case-insensitive; // surrounding whitespace is stripped. Returns an empty optional for unknown // values. diff --git a/include/mhda/network_type.hpp b/include/mhda/network_type.hpp index 6b01b89..565172c 100644 --- a/include/mhda/network_type.hpp +++ b/include/mhda/network_type.hpp @@ -41,6 +41,29 @@ class network_type { std::string value_; }; +// The named constants are inline variables defined in this header: every +// translation unit that names one includes their definitions, so they are +// initialised before any namespace-scope object defined after the include, +// and a consumer's global constructors and destructors can use them. +// +// Network types use the commonly accepted network names, lowercase. Family +// types that cover a single ecosystem carry that ecosystem's name (tron, +// avalanche); "evm" stays as-is because it covers many independent networks. +inline const network_type network_type::bitcoin {"bitcoin"}; +inline const network_type network_type::ethereum_vm {"evm"}; +inline const network_type network_type::avalanche_vm {"avalanche"}; +inline const network_type network_type::tron_vm {"tron"}; +inline const network_type network_type::cosmos {"cosmos"}; +inline const network_type network_type::solana {"solana"}; +inline const network_type network_type::xrp_ledger {"xrpl"}; +inline const network_type network_type::stellar {"stellar"}; +inline const network_type network_type::near_protocol {"near"}; +inline const network_type network_type::aptos {"aptos"}; +inline const network_type network_type::sui {"sui"}; +inline const network_type network_type::cardano {"cardano"}; +inline const network_type network_type::algorand {"algorand"}; +inline const network_type network_type::toncoin {"ton"}; + // network_type_from_string parses a string into a network_type. Lookup is // case-insensitive; surrounding whitespace is stripped. Returns an empty // optional for unknown values. diff --git a/src/algorithm.cpp b/src/algorithm.cpp index a1e20d5..b923c57 100644 --- a/src/algorithm.cpp +++ b/src/algorithm.cpp @@ -6,22 +6,16 @@ namespace mhda { -const algorithm algorithm::secp256k1 {"secp256k1"}; -const algorithm algorithm::ed25519 {"ed25519"}; -const algorithm algorithm::sr25519 {"sr25519"}; -const algorithm algorithm::secp256r1 {"secp256r1"}; -const algorithm algorithm::secp384r1 {"secp384r1"}; -const algorithm algorithm::secp521r1 {"secp521r1"}; -const algorithm algorithm::prime256v1 {"prime256v1"}; - namespace { const std::unordered_set& algo_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "secp256k1", "ed25519", "sr25519", "secp256r1", "secp384r1", "secp521r1", "prime256v1", }; - return index; + return *index; } } // namespace diff --git a/src/compatibility.cpp b/src/compatibility.cpp index ded5c18..8793307 100644 --- a/src/compatibility.cpp +++ b/src/compatibility.cpp @@ -17,7 +17,9 @@ struct network_compat { }; const std::unordered_map& matrix() { - static const std::unordered_map cache = []() { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* cache = new std::unordered_map([]() { std::unordered_map m; // Bitcoin m.emplace(network_type::bitcoin, network_compat{ @@ -135,8 +137,8 @@ const std::unordered_map& matrix() { format::base64url, }); return m; - }(); - return cache; + }()); + return *cache; } } // namespace diff --git a/src/derivation_type.cpp b/src/derivation_type.cpp index e9d60d0..8e34090 100644 --- a/src/derivation_type.cpp +++ b/src/derivation_type.cpp @@ -7,28 +7,17 @@ namespace mhda { -const derivation_type derivation_type::root {"root"}; -const derivation_type derivation_type::bip32 {"bip32"}; -const derivation_type derivation_type::bip44 {"bip44"}; -const derivation_type derivation_type::bip49 {"bip49"}; -const derivation_type derivation_type::bip54 {"bip54"}; -const derivation_type derivation_type::bip74 {"bip74"}; -const derivation_type derivation_type::bip84 {"bip84"}; -const derivation_type derivation_type::bip86 {"bip86"}; -const derivation_type derivation_type::slip10 {"slip10"}; -const derivation_type derivation_type::cip1852 {"cip1852"}; -const derivation_type derivation_type::cip11 {"cip11"}; -const derivation_type derivation_type::zip32 {"zip32"}; - namespace { const std::unordered_set& dt_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "root", "bip32", "bip44", "bip49", "bip54", "bip74", "bip84", "bip86", "slip10", "cip1852", "cip11", "zip32", }; - return index; + return *index; } } // namespace diff --git a/src/format.cpp b/src/format.cpp index 75b0fbf..35da878 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -6,29 +6,17 @@ namespace mhda { -const format format::hex {"hex"}; -const format format::p2pkh {"p2pkh"}; -const format format::p2sh {"p2sh"}; -const format format::p2wpkh {"p2wpkh"}; -const format format::p2wsh {"p2wsh"}; -const format format::p2tr {"p2tr"}; -const format format::bech32 {"bech32"}; -const format format::bech32m {"bech32m"}; -const format format::base58 {"base58"}; -const format format::base32 {"base32"}; -const format format::strkey {"strkey"}; -const format format::base64url {"base64url"}; -const format format::ss58 {"ss58"}; - namespace { const std::unordered_set& format_index() { - static const std::unordered_set index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_set{ "hex", "p2pkh", "p2sh", "p2wpkh", "p2wsh", "p2tr", "bech32", "bech32m", "base58", "base32", "strkey", "base64url", "ss58", }; - return index; + return *index; } } // namespace diff --git a/src/network_type.cpp b/src/network_type.cpp index a72eb90..2872364 100644 --- a/src/network_type.cpp +++ b/src/network_type.cpp @@ -6,28 +6,12 @@ namespace mhda { -// Network types use the commonly accepted network names, lowercase. Family -// types that cover a single ecosystem carry that ecosystem's name (tron, -// avalanche); "evm" stays as-is because it covers many independent networks. -const network_type network_type::bitcoin {"bitcoin"}; -const network_type network_type::ethereum_vm {"evm"}; -const network_type network_type::avalanche_vm {"avalanche"}; -const network_type network_type::tron_vm {"tron"}; -const network_type network_type::cosmos {"cosmos"}; -const network_type network_type::solana {"solana"}; -const network_type network_type::xrp_ledger {"xrpl"}; -const network_type network_type::stellar {"stellar"}; -const network_type network_type::near_protocol {"near"}; -const network_type network_type::aptos {"aptos"}; -const network_type network_type::sui {"sui"}; -const network_type network_type::cardano {"cardano"}; -const network_type network_type::algorand {"algorand"}; -const network_type network_type::toncoin {"ton"}; - namespace { const std::unordered_map& nt_index() { - static const std::unordered_map index = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* index = new std::unordered_map{ {"bitcoin", network_type::bitcoin}, {"evm", network_type::ethereum_vm}, {"avalanche", network_type::avalanche_vm}, @@ -43,7 +27,7 @@ const std::unordered_map& nt_index() { {"algorand", network_type::algorand}, {"ton", network_type::toncoin}, }; - return index; + return *index; } } // namespace diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index 714a01b..7b7b296 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -11,14 +11,16 @@ namespace detail { namespace { const std::unordered_set& known_components() { - static const std::unordered_set set = { + // Allocated once and never destroyed: a consumer's global destructor may + // still parse after static destruction has begun. + static const auto* set = new std::unordered_set{ comp_network_type, comp_chain_id, comp_coin_type, comp_derivation_type, comp_derivation_path, comp_address_algorithm, comp_address_format, comp_address_prefix, comp_address_suffix, comp_wallet_type, comp_wallet_id, }; - return set; + return *set; } } // namespace diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4363209..d08cf5d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(mhda_tests test_boundary.cpp test_hardening.cpp test_index_range.cpp + test_static_init.cpp ) target_link_libraries(mhda_tests PRIVATE mhda) target_include_directories(mhda_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/test_static_init.cpp b/tests/test_static_init.cpp new file mode 100644 index 0000000..f3eca12 --- /dev/null +++ b/tests/test_static_init.cpp @@ -0,0 +1,66 @@ +// mhda used from a consumer's namespace-scope objects: while the program is +// initialised (a built-in chain table, a URN validated up front, a lookup) +// and while it is torn down (a destructor that still parses). The named +// constants and the lookup tables must already exist in the first case and +// still exist in the second. + +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +constexpr const char* kURN = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"; + +// Defined before g_init, so it is constructed before g_init builds the +// lookup tables and destroyed after them, unless the tables outlive static +// destruction. A failure here can only end the process: exit code 3. +struct exit_parser { + ~exit_parser() { + try { + (void)parse_urn_strict(kURN); + } catch (const std::exception& e) { + std::fprintf(stderr, "parse_urn_strict at static destruction threw: %s\n", e.what()); + std::_Exit(3); + } + } +}; +exit_parser g_exit; + +struct init_probe { + std::string btc_key; + std::string evm_lookup; + std::string strict_error = "not run"; + + init_probe() { + try { + btc_key = chain{network_type::bitcoin, "0"}.key(); + auto nt = network_type_from_string("evm"); + evm_lookup = nt ? nt->str() : std::string{}; + (void)parse_urn_strict(kURN); + strict_error.clear(); + } catch (const std::exception& e) { + strict_error = e.what(); + } + } +}; +const init_probe g_init; + +} // namespace + +TEST_CASE("mhda works from a consumer's static initialisers") { + EXPECT_EQ(g_init.btc_key, std::string{"nt:bitcoin:ci:0"}); + EXPECT_EQ(g_init.evm_lookup, std::string{"evm"}); + EXPECT_EQ(g_init.strict_error, std::string{}); + // Tables first built during static initialisation stay right for the + // rest of the process. + EXPECT_NO_THROW(parse_urn_strict(kURN)); + EXPECT_NO_THROW(parse_urn_strict("urn:mhda:nt:bitcoin:ci:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh")); +} From 7555c7680e17b883757775c3ea8da620b0cbada8 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:40:19 +0300 Subject: [PATCH 05/21] Validate network type, chain id and derivation type set in code The network type, the chain id and the derivation type are written verbatim into every URN and chain key, but nothing checked them when they were set programmatically. A chain id such as 1:dt:bip44:dp:m/44'/60'/0'/0/666, set through the chain constructor or address::get_chain().set_chain_id on a root address, produced a URN that re-parsed as a bip44 path. The chain constructor, set_network and set_chain_id now require a registered network type and a non-empty chain id of printable ASCII without ':', '?' or '#' (ASCII-trimmed, the rule of the free-form setters, now shared through detail::validate_free_form_value) and throw parse_error otherwise, leaving the chain unchanged. The derivation_path constructor, from_levels and set_type throw parse_error(invalid_derivation_type) on an unregistered type. Mirrors go-mhda. --- CHANGELOG.md | 13 ++++++++ SPEC.md | 16 +++++++++- include/mhda/chain.hpp | 16 +++++++--- include/mhda/derivation_path.hpp | 8 ++++- src/address.cpp | 26 +++------------- src/chain.cpp | 22 +++++++++++++ src/derivation_path.cpp | 12 ++++++++ src/detail.cpp | 14 +++++++++ src/detail.hpp | 10 ++++++ tests/test_edge.cpp | 14 ++++----- tests/test_hardening.cpp | 53 ++++++++++++++++++++++++++++++++ 11 files changed, 169 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d781e7..97ce1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,19 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. index is `2147483647`. The `ct` component is not a path level and keeps its 32-bit range. +- **Programmatic values are validated like parsed input.** The network + type, the chain id and the derivation type are written verbatim into + every URN, and nothing checked them when set in code: a chain id + `1:dt:bip44:dp:m/44'/60'/0'/0/666` on a root address produced a URN that + re-parsed as a bip44 path. The `chain` constructor, `set_network` and + `set_chain_id` now require a registered network type and a non-empty + chain id of printable ASCII without `:`, `?` or `#` (ASCII-trimmed) and + throw `parse_error` (`invalid_network_type`, `missing_chain_id`, + `invalid_value`) otherwise, leaving the chain unchanged. The + `derivation_path` constructor, `from_levels` and `set_type` throw + `parse_error(invalid_derivation_type)` on an unregistered type. Mirrors + go-mhda. + ### Fixed - **Static initialisation and destruction order.** The named constants diff --git a/SPEC.md b/SPEC.md index 00492cc..64f6b39 100644 --- a/SPEC.md +++ b/SPEC.md @@ -366,7 +366,7 @@ text. | `ErrMissingChainID` | `ci` absent | | `ErrCoinTypeInChainKey` | `ChainFromKey` input carries `ct` (pre-1.1 key format) | | `ErrInvalidChainKey` | `ChainFromKey` input is not the canonical identity form | -| `ErrInvalidValue` | Free-form setter value with `:`/`?`/`#`/whitespace | +| `ErrInvalidValue` | Free-form or chain id value with `:`/`?`/`#`/whitespace | | `ErrInvalidDerivationType` | `dt` value not registered | | `ErrInvalidDerivationPath` | `dp` does not match `dt`, or a level index is >= 2^31 | | `ErrInvalidAlgorithm` | `aa` value not registered | @@ -418,6 +418,20 @@ with its own factory functions: | `ChainFromNSS(s string)` | Extract the chain domain from any NSS (lenient). | | `ChainFromKey(key)` | Parse a `ChainKey` (alias of string) produced by `Key()`.| +Values set programmatically are written verbatim into every URN and chain +key, so they are validated like parsed input. `NewChain`, +`Chain.SetNetworkType` and `Chain.SetChainId` require a registered network +type and a non-empty chain id (ASCII-trimmed) of printable ASCII without +`:`, `?` or `#`; a `:` would otherwise inject components on re-parse. +`NewChain` panics on an invalid value; the setters return +`ErrInvalidNetworkType`, `ErrMissingChainID` or `ErrInvalidValue` and leave +the chain unchanged. `NewDerivationPath` and `NewDerivationPathFromLevels` +panic with `ErrInvalidDerivationType` on an unregistered derivation type. +(C++: the `chain` constructor, `chain::set_network` and +`chain::set_chain_id` throw `parse_error` with the matching code and leave +the chain unchanged; the `derivation_path` constructor, `from_levels` and +`set_type` throw `parse_error(invalid_derivation_type)`.) + `Chain.String()` and `Chain.Key()` both return the canonical chain key `nt::ci:`, suitable for use as a map key, cache key or content hash input. The format is a strict prefix of any full URN NSS, so diff --git a/include/mhda/chain.hpp b/include/mhda/chain.hpp index 51a27c8..0a1142b 100644 --- a/include/mhda/chain.hpp +++ b/include/mhda/chain.hpp @@ -33,8 +33,14 @@ using chain_key = std::string; class chain { public: chain() = default; - chain(network_type nt, chain_id ci) - : network_(std::move(nt)), chain_id_(std::move(ci)) {} + + // The network type and the chain id are written verbatim into every URN + // and chain key, so they are validated like parsed input: the network + // type must be registered (error_code::invalid_network_type), and the + // chain id, ASCII-trimmed, must be non-empty (missing_chain_id) printable + // ASCII without ':', '?' or '#' (invalid_value) - a ':' would inject + // components on re-parse. Throws parse_error. + chain(network_type nt, chain_id ci); // from_key parses a chain key produced by chain::key(). A chain key is // the canonical identity form "nt::ci:" and nothing @@ -52,8 +58,10 @@ class chain { // tolerated and ignored, so a full address NSS is valid input. static chain from_nss(std::string_view nss); - void set_network(network_type nt) { network_ = std::move(nt); } - void set_chain_id(chain_id ci) { chain_id_ = std::move(ci); } + // set_network / set_chain_id validate like the constructor and throw + // parse_error; on error the chain is left unchanged. + void set_network(network_type nt); + void set_chain_id(chain_id ci); // set_coin attaches the optional SLIP-44 coin-type metadata. void set_coin(coin_type ct) { coin_ = ct; } diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index 57e46c8..7b1643b 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -51,6 +51,9 @@ class derivation_path { // Construct a path for a fixed-shape BIP-family scheme. SLIP-10 cannot be // reconstructed from the five shortcut fields; passing it here throws // std::invalid_argument (mirroring the panic in the Go implementation). + // An unregistered derivation type throws + // parse_error(invalid_derivation_type): the type is written verbatim + // into the URN. derivation_path(derivation_type dt, coin_type coin, account_index account, @@ -59,7 +62,8 @@ class derivation_path { // Construct a path from an explicit sequence of levels. Required for // SLIP-10 and a convenient alternative for any scheme. For fixed-shape - // schemes the shortcut fields are populated from the levels. + // schemes the shortcut fields are populated from the levels. An + // unregistered derivation type throws parse_error(invalid_derivation_type). static derivation_path from_levels(derivation_type dt, std::vector levels); @@ -88,6 +92,8 @@ class derivation_path { // construction or set_type); for ROOT, path must be empty. void parse_path(std::string_view path); + // set_type throws parse_error(invalid_derivation_type) for an + // unregistered type. void set_type(const derivation_type& dt); private: diff --git a/src/address.cpp b/src/address.cpp index 08dfc44..1f9ff52 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -22,24 +22,6 @@ void append_uint32(std::string& out, std::uint32_t v) { for (int i = n - 1; i >= 0; --i) out.push_back(tmp[i]); } -// validate_free_form_value guards the case-preserving free-form components -// (ap/as/wt/wi) against characters that would corrupt the serialised NSS: -// the ':' component separator would inject foreign components on re-parse, -// '?' / '#' would truncate the URN at the RFC 8141 r/q/f delimiters, and -// anything outside printable ASCII (whitespace of any kind, control bytes, -// Unicode) cannot appear in a conforming NSS at all. Mirrors the Go -// reference's validateFreeFormValue. -void validate_free_form_value(std::string_view component, std::string_view v) { - for (char c : v) { - const auto b = static_cast(c); - if (b < 0x21 || b > 0x7e || c == ':' || c == '?' || c == '#') { - throw parse_error(error_code::invalid_value, - std::string{"\""} + std::string{v} + "\" for \"" + - std::string{component} + "\""); - } - } -} - } // namespace address::address(chain c, @@ -143,25 +125,25 @@ void address::set_address_format(std::string_view af) { void address::set_address_prefix(std::string_view ap) { auto trimmed = detail::trim(ap); - validate_free_form_value(detail::comp_address_prefix, trimmed); + detail::validate_free_form_value(detail::comp_address_prefix, trimmed); prefix_ = std::string{trimmed}; } void address::set_address_suffix(std::string_view as) { auto trimmed = detail::trim(as); - validate_free_form_value(detail::comp_address_suffix, trimmed); + detail::validate_free_form_value(detail::comp_address_suffix, trimmed); suffix_ = std::string{trimmed}; } void address::set_wallet_type(std::string_view wt) { auto trimmed = detail::trim(wt); - validate_free_form_value(detail::comp_wallet_type, trimmed); + detail::validate_free_form_value(detail::comp_wallet_type, trimmed); wallet_type_ = std::string{trimmed}; } void address::set_wallet_id(std::string_view wi) { auto trimmed = detail::trim(wi); - validate_free_form_value(detail::comp_wallet_id, trimmed); + detail::validate_free_form_value(detail::comp_wallet_id, trimmed); wallet_id_ = std::string{trimmed}; } diff --git a/src/chain.cpp b/src/chain.cpp index 45091af..7869b89 100644 --- a/src/chain.cpp +++ b/src/chain.cpp @@ -61,6 +61,28 @@ chain build_chain_from_components(const std::unordered_map levels) { + if (!dt.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + std::string{"\""} + dt.str() + "\""); + } derivation_path dp; dp.type_ = std::move(dt); dp.levels_ = std::move(levels); @@ -217,6 +225,10 @@ derivation_path derivation_path::parse(derivation_type dt, std::string_view path } void derivation_path::set_type(const derivation_type& dt) { + if (!dt.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + std::string{"\""} + dt.str() + "\""); + } type_ = dt; } diff --git a/src/detail.cpp b/src/detail.cpp index 97a4ada..449d83a 100644 --- a/src/detail.cpp +++ b/src/detail.cpp @@ -2,6 +2,9 @@ #include #include +#include + +#include "mhda/error.hpp" namespace mhda { namespace detail { @@ -51,6 +54,17 @@ bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept { return true; } +void validate_free_form_value(std::string_view component, std::string_view v) { + for (char c : v) { + const auto b = static_cast(c); + if (b < 0x21 || b > 0x7e || c == ':' || c == '?' || c == '#') { + throw parse_error(error_code::invalid_value, + std::string{"\""} + std::string{v} + "\" for \"" + + std::string{component} + "\""); + } + } +} + bool parse_uint31_dec(std::string_view s, std::uint32_t& out) noexcept { std::uint64_t value = 0; if (!parse_in_base(s, 10, value)) return false; diff --git a/src/detail.hpp b/src/detail.hpp index b0474f6..8fa9797 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -92,6 +92,16 @@ inline std::vector split(std::string_view s, char sep) { // the coin-type ("ct") parsing paths. bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept; +// validate_free_form_value guards a value written verbatim into the NSS +// (ap/as/wt/wi and the chain id) against characters that would corrupt it: +// the ':' component separator would inject foreign components on re-parse, +// '?' / '#' would truncate the URN at the RFC 8141 r/q/f delimiters, and +// anything outside printable ASCII (whitespace of any kind, control bytes, +// Unicode) cannot appear in a conforming NSS at all. Throws +// parse_error(invalid_value). Mirrors the Go reference's +// validateFreeFormValue. +void validate_free_form_value(std::string_view component, std::string_view v); + // parse_uint31_dec parses a strictly decimal value below 2^31. Used by // per-level derivation-path parsing: a BIP-32 child number keeps the hardened // flag in its top bit (n' is 2^31+n), so a level index has 31 bits. Mirrors diff --git a/tests/test_edge.cpp b/tests/test_edge.cpp index db0bf68..37fe5bd 100644 --- a/tests/test_edge.cpp +++ b/tests/test_edge.cpp @@ -53,14 +53,14 @@ TEST_CASE("unmarshal_text rejection leaves receiver intact") { EXPECT_EQ(a.str(), before); // strong exception guarantee } -TEST_CASE("validate rejects unknown network even with valid algo/fmt") { - address a; +TEST_CASE("an unregistered network cannot be set on a chain") { + // set_network validates, so an address can no longer carry an + // unregistered network for validate() to meet. chain c; - c.set_network(network_type{"polkadot"}); // not registered - c.set_coin(354); - c.set_chain_id("mainnet"); - a = address{c, std::nullopt}; - EXPECT_THROW_CODE(a.validate(), error_code::incompatible); + EXPECT_THROW_CODE(c.set_network(network_type{"polkadot"}), error_code::invalid_network_type); + EXPECT_TRUE(c.network().empty()); + address a{c, std::nullopt}; + EXPECT_THROW_CODE(a.validate(), error_code::uninitialized_address); } TEST_CASE("validate of zero-value address yields uninitialized_address") { diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp index c1d590d..15be51a 100644 --- a/tests/test_hardening.cpp +++ b/tests/test_hardening.cpp @@ -171,3 +171,56 @@ TEST_CASE("explicit ct:0 round-trips at URN level") { if (addr.get_chain().coin()) EXPECT_EQ(*addr.get_chain().coin(), 0u); EXPECT_EQ(addr.str(), in); } + +// The network type, the chain id and the derivation type are written +// verbatim into every URN. A value carrying ':' would inject components on +// re-parse (a root address whose URN reads back as a bip44 path), '?' / '#' +// would truncate it. Constructors and setters throw parse_error on such a +// value; a failed setter keeps the old value. Mirrors go-mhda. +TEST_CASE("programmatic values cannot inject components") { + struct row { network_type nt; std::string ci; error_code want; }; + const std::vector rows = { + {network_type::ethereum_vm, "1:dt:bip44:dp:m/44'/60'/0'/0/666", error_code::invalid_value}, + {network_type::ethereum_vm, "1:wi:attacker", error_code::invalid_value}, + {network_type::ethereum_vm, "1?=q", error_code::invalid_value}, + {network_type::ethereum_vm, "1#f", error_code::invalid_value}, + {network_type::ethereum_vm, "a b", error_code::invalid_value}, + {network_type::ethereum_vm, "\xc2\xa0" "1", error_code::invalid_value}, + {network_type::ethereum_vm, "", error_code::missing_chain_id}, + {network_type::ethereum_vm, " \t", error_code::missing_chain_id}, + {network_type{"evm:ci:1:dt:bip44"}, "1", error_code::invalid_network_type}, + {network_type{"polkadot"}, "1", error_code::invalid_network_type}, + {network_type{"EVM"}, "1", error_code::invalid_network_type}, + {network_type{}, "1", error_code::invalid_network_type}, + }; + for (const auto& r : rows) { + EXPECT_THROW_CODE((chain{r.nt, r.ci}), r.want); + } + + const chain trimmed{network_type::ethereum_vm, " 0x1 "}; + EXPECT_EQ(trimmed.id(), std::string{"0x1"}); + + chain ch{network_type::ethereum_vm, "1"}; + EXPECT_THROW_CODE(ch.set_chain_id("1:dt:bip44"), error_code::invalid_value); + EXPECT_THROW_CODE(ch.set_chain_id(""), error_code::missing_chain_id); + EXPECT_THROW_CODE(ch.set_network(network_type{"evm:x"}), error_code::invalid_network_type); + EXPECT_EQ(ch.str(), std::string{"nt:evm:ci:1"}); + EXPECT_NO_THROW(ch.set_chain_id("56")); + EXPECT_NO_THROW(ch.set_network(network_type::bitcoin)); + EXPECT_EQ(ch.str(), std::string{"nt:bitcoin:ci:56"}); + + // The mutable accessor goes through the same setters. + auto a = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_THROW_CODE(a.get_chain().set_chain_id("1:dt:bip44:dp:m/44'/60'/0'/0/666"), + error_code::invalid_value); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); + + for (const char* dt : {"bip44:wi:x", "bogus", "BIP44", ""}) { + EXPECT_THROW_CODE((derivation_path{derivation_type{dt}, 60, 0, 0, address_index{}}), + error_code::invalid_derivation_type); + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type{dt}, {}), + error_code::invalid_derivation_type); + derivation_path p; + EXPECT_THROW_CODE(p.set_type(derivation_type{dt}), error_code::invalid_derivation_type); + } +} From 9a7f072964db68767d88ffae5d6e250df5b1757c Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:41:03 +0300 Subject: [PATCH 06/21] Refuse a derivation path without a derivation type or under root set_derivation_path returned early for a root address, so a dp with no dt (which parses as root) or with dt:root was dropped silently: urn:mhda:nt:evm:ci:1:dp:m/44'/60'/0'/0/0 parsed as urn:mhda:nt:evm:ci:1, naming the root key instead of the path it spells out. derivation_path::parse_path already refused a non-empty path for root; the address setter now does the same and throws invalid_derivation_path. An empty dp under root stays a no-op. Mirrors go-mhda. tests/data/urn_conformance.txt is the URN-level table shared verbatim with go-mhda (testdata/urn_conformance.txt), run through parse_urn / parse_nss; this change adds its first rows. --- CHANGELOG.md | 7 +++ SPEC.md | 9 +++- include/mhda/address.hpp | 6 ++- src/address.cpp | 12 ++++- tests/CMakeLists.txt | 3 +- tests/data/urn_conformance.txt | 28 ++++++++++ tests/test_parse.cpp | 8 ++- tests/test_urn_conformance.cpp | 99 ++++++++++++++++++++++++++++++++++ 8 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 tests/data/urn_conformance.txt create mode 100644 tests/test_urn_conformance.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ce1f8..04c3a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,13 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. `derivation_path` constructor, `from_levels` and `set_type` throw `parse_error(invalid_derivation_type)` on an unregistered type. Mirrors go-mhda. +- **A derivation path without a derivation type, or under `dt:root`, is + refused** with `parse_error(invalid_derivation_path)`. Both used to be + dropped silently: `urn:mhda:nt:evm:ci:1:dp:m/44'/60'/0'/0/0` and + `urn:mhda:nt:evm:ci:1:dt:root:dp:m/0` parsed as the root address + `urn:mhda:nt:evm:ci:1`, naming the root key instead of the path they + spell out. `address::set_derivation_path` on a root address accepts only + an empty path. ### Fixed diff --git a/SPEC.md b/SPEC.md index 64f6b39..85c20f0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -51,6 +51,11 @@ Optional components are emitted in canonical output ONLY when explicitly set. A short input form round-trips back to the same short form; a long form round-trips back to the same long form. +`dp` is present exactly when `dt` is a type other than `root`. A `dp` +without `dt` (which makes the address root) or with `dt:root` is refused +with `ErrInvalidDerivationPath`: dropping it would make the URN name the root +key instead of the path it spells out. + The chain identity is the `(nt, ci)` pair. The `ct` component is OPTIONAL SLIP-44 coin-type metadata: it never participates in the chain identity or in chain keys (see §1.6 and §10). For HD addresses the coin type is already @@ -323,6 +328,8 @@ Performs structural validation only: - Derivation type, if present, is one of the registered constants. - Derivation path, if present, matches the regex of its derivation type, and every level's index is below 2^31 (§3). +- Derivation path is present if and only if the derivation type is set and + is not `root` (§1.2). - Algorithm, if present, is one of the registered constants. - Format, if present, is one of the registered constants. @@ -368,7 +375,7 @@ text. | `ErrInvalidChainKey` | `ChainFromKey` input is not the canonical identity form | | `ErrInvalidValue` | Free-form or chain id value with `:`/`?`/`#`/whitespace | | `ErrInvalidDerivationType` | `dt` value not registered | -| `ErrInvalidDerivationPath` | `dp` does not match `dt`, or a level index is >= 2^31 | +| `ErrInvalidDerivationPath` | `dp` does not fit `dt` (or no `dt`), or an index >= 2^31 | | `ErrInvalidAlgorithm` | `aa` value not registered | | `ErrInvalidFormat` | `af` value not registered | | `ErrIncompatible` | Strict validation: triple not allowed for the network | diff --git a/include/mhda/address.hpp b/include/mhda/address.hpp index 83ad07d..bc61d7a 100644 --- a/include/mhda/address.hpp +++ b/include/mhda/address.hpp @@ -73,8 +73,10 @@ class address { // resolves to ROOT. Allocates a fresh derivation_path if none was attached. void set_derivation_type(std::string_view dt); - // set_derivation_path validates and applies the textual path. If the path's - // derivation type is ROOT this is a silent no-op. Throws parse_error. + // set_derivation_path validates and applies the textual path. A root + // address has no path: an empty (or all-whitespace) dp is a no-op, any + // other dp throws parse_error(invalid_derivation_path). Throws + // parse_error. void set_derivation_path(std::string_view dp); // set_coin_type sets the chain's optional coin-type metadata from a diff --git a/src/address.cpp b/src/address.cpp index 1f9ff52..36672bb 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -71,10 +71,18 @@ void address::set_derivation_type(std::string_view dt) { void address::set_derivation_path(std::string_view dp) { if (!path_) path_.emplace(); + auto trimmed = detail::trim(dp); if (path_->type() == derivation_type::root || path_->type().empty()) { - return; // root has no path; silent no-op matches Go semantics + // A root address has no path. Dropping one instead would let a URN + // with a dp but no dt (which parses as root) name the root key rather + // than the path it spells out. Mirrors go-mhda. + if (!trimmed.empty()) { + throw parse_error(error_code::invalid_derivation_path, + std::string{"root derivation must have empty path, got \""} + + std::string{trimmed} + "\""); + } + return; } - auto trimmed = detail::trim(dp); auto lowered = detail::to_lower(trimmed); path_->parse_path(lowered); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d08cf5d..bdb18f0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,10 +13,11 @@ add_executable(mhda_tests test_hardening.cpp test_index_range.cpp test_static_init.cpp + test_urn_conformance.cpp ) target_link_libraries(mhda_tests PRIVATE mhda) target_include_directories(mhda_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) -# The derivation-path conformance table shared with go-mhda. +# The conformance tables shared with go-mhda. target_compile_definitions(mhda_tests PRIVATE MHDA_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt new file mode 100644 index 0000000..4e15661 --- /dev/null +++ b/tests/data/urn_conformance.txt @@ -0,0 +1,28 @@ +# URN conformance table, shared verbatim by go-mhda +# (testdata/urn_conformance.txt) and the C++ port (tests/data/urn_conformance.txt). +# Both test suites run every row through the lenient parser; an input one +# implementation refuses, the other refuses too, with the same error. Edit +# both copies together. +# +# Row forms: +# accept urn +# accept nss +# refuse urn +# refuse nss +# is the C++ error_code name; go-mhda maps it to its sentinel +# (invalid_nss is ErrInvalidNSS, invalid_derivation_path is +# ErrInvalidDerivationPath, ...). + +# A derivation path needs a derivation type other than root. Without dt the +# address is root, and a dp there would be dropped: the URN would name the +# root key instead of the path it spells out. +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dp:m/44'/60'/0'/0/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:root:dp:m/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:ROOT:dp:m/0 +refuse urn invalid_derivation_path urn:mhda:nt:ton:ci:mainnet:dt:root:dp:m/44'/607'/0' +refuse urn invalid_derivation_path urn:mhda:dp:m/44'/60'/0'/0/0:nt:evm:ci:1 +refuse nss invalid_derivation_path nt:evm:ci:1:dp:m/44'/60'/0'/0/0 +refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:bip44 +accept urn urn:mhda:nt:ton:ci:mainnet urn:mhda:nt:ton:ci:mainnet +accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 +accept nss nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 diff --git a/tests/test_parse.cpp b/tests/test_parse.cpp index 82fc257..ebb7878 100644 --- a/tests/test_parse.cpp +++ b/tests/test_parse.cpp @@ -169,9 +169,13 @@ TEST_CASE("set prefix/suffix reset") { EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } -TEST_CASE("set_derivation_path is no-op for ROOT") { +TEST_CASE("set_derivation_path refuses a path under ROOT") { + // Silently ignoring it would make the address name the root key instead + // of the path given. address a{chain{network_type::ethereum_vm, "1"}, std::nullopt}; a.set_derivation_type(""); - a.set_derivation_path("m/44'/0'/0'/0/0"); // silently ignored + EXPECT_THROW_CODE(a.set_derivation_path("m/44'/0'/0'/0/0"), + error_code::invalid_derivation_path); + EXPECT_NO_THROW(a.set_derivation_path("")); EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); } diff --git a/tests/test_urn_conformance.cpp b/tests/test_urn_conformance.cpp new file mode 100644 index 0000000..fc707eb --- /dev/null +++ b/tests/test_urn_conformance.cpp @@ -0,0 +1,99 @@ +// URN-level conformance table shared verbatim with go-mhda +// (testdata/urn_conformance.txt there): an input one implementation refuses, +// the other refuses too, with the same error. + +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +namespace { + +const std::map& conformance_errors() { + static const std::map m = { + {"invalid_urn", error_code::invalid_urn}, + {"invalid_nss", error_code::invalid_nss}, + {"missing_network_type", error_code::missing_network_type}, + {"invalid_network_type", error_code::invalid_network_type}, + {"invalid_coin_type", error_code::invalid_coin_type}, + {"missing_chain_id", error_code::missing_chain_id}, + {"invalid_derivation_type", error_code::invalid_derivation_type}, + {"invalid_derivation_path", error_code::invalid_derivation_path}, + {"invalid_algorithm", error_code::invalid_algorithm}, + {"invalid_format", error_code::invalid_format}, + {"invalid_value", error_code::invalid_value}, + }; + return m; +} + +} // namespace + +TEST_CASE("URN conformance table shared with go-mhda") { + std::ifstream in(MHDA_TEST_DATA_DIR "/urn_conformance.txt"); + if (!in) { + mhda_failures.push_back({__FILE__, __LINE__, + "cannot open " MHDA_TEST_DATA_DIR "/urn_conformance.txt"}); + return; + } + + int rows = 0; + std::string text; + for (int line = 1; std::getline(in, text); ++line) { + if (text.empty() || text[0] == '#') continue; + std::istringstream fields(text); + std::string verdict, kind, a, b, extra; + fields >> verdict >> kind >> a >> b >> extra; + const std::string where = "urn_conformance.txt:" + std::to_string(line) + ": " + text; + if (!extra.empty() || b.empty() || (kind != "urn" && kind != "nss") || + (verdict != "accept" && verdict != "refuse")) { + mhda_failures.push_back({__FILE__, __LINE__, "malformed row, " + where}); + return; + } + ++rows; + const bool accept = verdict == "accept"; + const std::string& input = accept ? a : b; + error_code want{}; + if (!accept) { + auto it = conformance_errors().find(a); + if (it == conformance_errors().end()) { + mhda_failures.push_back({__FILE__, __LINE__, "unknown error name, " + where}); + return; + } + want = it->second; + } + + try { + const address addr = kind == "urn" ? parse_urn(input) : parse_nss(input); + if (!accept) { + mhda_failures.push_back({__FILE__, __LINE__, "accepted, " + where}); + } else if (addr.str() != b) { + mhda_failures.push_back({__FILE__, __LINE__, + "str() = " + addr.str() + ", " + where}); + } + } catch (const parse_error& e) { + if (accept || e.code() != want) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"threw "} + e.what() + ", " + where}); + } + } + } + if (rows == 0) { + mhda_failures.push_back({__FILE__, __LINE__, "urn_conformance.txt: no rows"}); + } +} + +// The setter refuses a path while the type is root, and accepts an empty one. +TEST_CASE("set_derivation_path under root") { + auto addr = parse_urn("urn:mhda:nt:evm:ci:1"); + EXPECT_THROW_CODE(addr.set_derivation_path("m/44'/60'/0'/0/0"), + error_code::invalid_derivation_path); + EXPECT_NO_THROW(addr.set_derivation_path("")); + EXPECT_NO_THROW(addr.set_derivation_path(" ")); + EXPECT_EQ(addr.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} From 3e08eb6607f4980227b25f99365d6d30287c403d Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:42:04 +0300 Subject: [PATCH 07/21] Parse the NSS strictly as key:value pairs parse_nss_map skipped an unknown token on its own, so the token after it was read as a key: a value could become a key and a known key could become a value. A key with the wrong case was skipped with it, dropping the component silently. ...:ci:1:DT:bip44:DP:m/44'/60'/0'/0/5 parsed as the root address urn:mhda:nt:evm:ci:1, and ...:ci:1:ext:wi:dt:bip44:... took dt as the wallet id. The NSS is now split into pairs: an odd number of tokens is refused, an unknown component is skipped together with its value, and a key that differs from a known key only by case throws invalid_nss. Keys must be non-empty printable ASCII, like values. An empty NSS still has no components. chain::from_key keeps reporting a dangling token as invalid_chain_key. Mirrors go-mhda. --- CHANGELOG.md | 9 +++++++++ SPEC.md | 11 ++++++++++- src/chain.cpp | 9 +++++++++ src/nss_parser.cpp | 35 +++++++++++++++++++++++----------- src/nss_parser.hpp | 20 ++++++++++++------- tests/data/urn_conformance.txt | 21 ++++++++++++++++++++ 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c3a64..88ca7a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,15 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. `urn:mhda:nt:evm:ci:1`, naming the root key instead of the path they spell out. `address::set_derivation_path` on a root address accepts only an empty path. +- **The NSS is parsed strictly as `key:value` pairs.** An unknown key was + skipped as a single token, so its value was read as the next key and a + key with the wrong case vanished: `...:ci:1:DT:bip44:DP:m/44'/60'/0'/0/5` + parsed as the root address `urn:mhda:nt:evm:ci:1`, and + `...:ci:1:ext:wi:dt:bip44:...` read `dt` as the wallet id. An unknown + component is now skipped together with its value; a key that differs from + a known key only by case, a dangling token, a trailing `:` and an empty + key throw `parse_error(invalid_nss)`. `chain::from_key` reports a + dangling token as `invalid_chain_key`, as before. ### Fixed diff --git a/SPEC.md b/SPEC.md index 85c20f0..5d98bb7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -27,7 +27,16 @@ urn:mhda:nt::ci::ct::dt::dp::aa: parse_nss_map(std::string_view nss) { std::unordered_map out; + if (nss.empty()) return out; auto parts = split(nss, ':'); - for (std::size_t i = 0; i < parts.size();) { - auto key = parts[i]; - if (!is_known_component(key)) { - // Unknown token (could be an unrelated word, a future component - // name, or part of a value we mis-identified). Skip and move on. - ++i; - continue; + if (parts.size() % 2 != 0) { + throw parse_error(error_code::invalid_nss, + std::string{"missing value for \""} + std::string{parts.back()} + "\""); + } + for (std::size_t i = 0; i < parts.size(); i += 2) { + const auto key = parts[i]; + if (key.empty()) { + throw parse_error(error_code::invalid_nss, "empty component key"); } - if (i + 1 >= parts.size()) { - throw parse_error(error_code::invalid_nss, - std::string{"missing value for \""} + std::string{key} + "\""); + for (char c : key) { + const auto b = static_cast(c); + if (b < 0x21 || b > 0x7e) { + throw parse_error(error_code::invalid_nss, + std::string{"non-ASCII or control byte in component key \""} + + std::string{key} + "\""); + } } // RFC 8141 NSS does not permit unescaped whitespace; trim ASCII // whitespace so any trailing space (e.g. from "ci:0 #frag" where @@ -64,13 +70,20 @@ std::unordered_map parse_nss_map(std::string_view nss) std::string{key} + "\""); } } + if (!is_known_component(key)) { + if (is_known_component(to_lower(key))) { + throw parse_error(error_code::invalid_nss, + std::string{"component key \""} + std::string{key} + + "\" must be lowercase"); + } + continue; // unknown component, skipped with its value + } std::string key_str{key}; if (out.count(key_str)) { throw parse_error(error_code::invalid_nss, std::string{"duplicate component \""} + key_str + "\""); } out.emplace(std::move(key_str), std::string{value}); - i += 2; } return out; } diff --git a/src/nss_parser.hpp b/src/nss_parser.hpp index d2ccc6c..efd0941 100644 --- a/src/nss_parser.hpp +++ b/src/nss_parser.hpp @@ -32,14 +32,20 @@ constexpr std::string_view comp_wallet_id = "wi"; bool is_known_component(std::string_view key) noexcept; -// parse_nss splits an NSS string into a component map. Unknown keys are -// silently skipped (forward-compat with future URN extensions); duplicate -// keys, empty values, and values containing anything outside printable ASCII -// (0x21–0x7E: control bytes, whitespace of any kind, non-ASCII bytes) throw -// parse_error(invalid_nss). Values are ASCII-trimmed before the checks. +// parse_nss splits an NSS string into a component map; an empty NSS has no +// components. The NSS is a sequence of `key:value` pairs joined by `:` +// separators. A pair with an unknown key is skipped together with its value +// (forward-compat with future URN extensions), so a value is never read as a +// key and a following known key is never read as a value. These throw +// parse_error(invalid_nss): a key without a value (an odd number of tokens), +// an empty key or value, a key that differs from a known key only by case +// (it would drop the component silently), a duplicate known key, and a key +// or value with anything outside printable ASCII (0x21–0x7E: control bytes, +// whitespace of any kind, non-ASCII bytes). Values are ASCII-trimmed before +// the checks. Mirrors go-mhda's parseNSS. // -// Form: a sequence of `key:value` pairs joined by `:` separators. Values may -// not contain ':'; this holds for every component currently defined. +// Values may not contain ':'; this holds for every component currently +// defined. std::unordered_map parse_nss_map(std::string_view nss); } // namespace detail diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt index 4e15661..995e633 100644 --- a/tests/data/urn_conformance.txt +++ b/tests/data/urn_conformance.txt @@ -26,3 +26,24 @@ refuse urn invalid_derivation_path urn:mhda:nt:evm:ci:1:dt:bip44 accept urn urn:mhda:nt:ton:ci:mainnet urn:mhda:nt:ton:ci:mainnet accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 accept nss nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 + +# The NSS is a sequence of key:value pairs. An unknown key is skipped +# together with its value (forward compatibility), so a following known key +# is never read as its value. A key that differs from a known key only by +# case is refused rather than skipped, and so is a token without a value. +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:DT:bip44:DP:m/44'/60'/0'/0/5 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:WI:wallet-A +refuse urn invalid_nss urn:mhda:NT:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:Ci:1 +accept urn urn:mhda:nt:evm:ci:1:ext:wi:dt:bip44:dp:m/44'/60'/0'/0/5 urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/5 +accept urn urn:mhda:nt:evm:ci:1:xx:future urn:mhda:nt:evm:ci:1 +accept urn urn:mhda:xx:nt:nt:evm:ci:1 urn:mhda:nt:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:garbage +refuse urn invalid_nss urn:mhda:nt:evm:junk:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1: +refuse urn invalid_nss urn:mhda::nt:evm:ci:1 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1::x +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:xx: +refuse urn missing_network_type urn:mhda: +refuse nss invalid_nss nt:evm:ci:1:foo +accept nss nt:evm:ci:1:wt:web3 urn:mhda:nt:evm:ci:1:wt:web3 From 322ee3e19bcd2b882c8ace5edbf2196edb58f229 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:42:37 +0300 Subject: [PATCH 08/21] Refuse '?' and '#' in an NSS parsed on its own parse_urn strips the RFC 8141 r/q/f components at the first '?' or '#', but parse_nss, chain::from_nss and chain::from_key took the NSS as given and kept the byte in a value, so the URN emitted from such an address was truncated on the next parse. The chain-id check of the previous change caught it in ci with the wrong code; in other values (nt:evm:ci:1:x#y:z) it passed. parse_nss_map now refuses either byte with invalid_nss, as go-mhda does. The NSS fuzz test only exercised str() and nss(); it now re-parses the emitted URN and compares, with the truncating inputs as seeds. --- CHANGELOG.md | 6 ++++++ SPEC.md | 5 +++++ src/nss_parser.cpp | 8 ++++++++ src/nss_parser.hpp | 4 +++- tests/data/urn_conformance.txt | 10 ++++++++++ tests/test_chain.cpp | 9 +++++++++ tests/test_fuzz.cpp | 18 +++++++++++++++--- 7 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ca7a8..f8aab29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,12 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. a known key only by case, a dangling token, a trailing `:` and an empty key throw `parse_error(invalid_nss)`. `chain::from_key` reports a dangling token as `invalid_chain_key`, as before. +- **`parse_nss`, `chain::from_nss` and `chain::from_key` refuse `?` and + `#`** with `parse_error(invalid_nss)`. `parse_urn` strips the RFC 8141 + r/q/f components before parsing, but an NSS given on its own kept the + byte in a value, so the URN emitted from it was truncated on the next + parse (`nt:evm:ci:1:x#y:z` was accepted). The NSS fuzz test now checks + that the emitted URN re-parses to itself. ### Fixed diff --git a/SPEC.md b/SPEC.md index 5d98bb7..91b6f7f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -97,6 +97,11 @@ interpreted; they are stripped before parsing: Example: `urn:mhda:nt:evm:ci:1?+resolver=example.com#sec` parses identically to the bare URN; `String()` does not preserve these elements. +An NSS given on its own (`ParseNSS`, `ChainFromNSS`, `ChainFromKey`) has no +r/q/f components to strip, so it must not contain `?` or `#` at all; such +input is refused with `ErrInvalidNSS`. Kept in a value, the byte would +truncate the URN emitted from it on the next parse. + ### 1.5 Charset NSS values must consist of ASCII characters allowed by RFC 8141 NSS production diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index e8df1ef..ad6508e 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -32,6 +32,14 @@ bool is_known_component(std::string_view key) noexcept { std::unordered_map parse_nss_map(std::string_view nss) { std::unordered_map out; if (nss.empty()) return out; + // '?' and '#' open the RFC 8141 r/q/f components. parse_urn strips them + // before the NSS reaches this parser; an NSS that still carries one + // (parse_nss, chain::from_nss, chain::from_key) is rejected, since the + // URN emitted from it would be truncated at that byte on the next parse. + if (const auto i = nss.find_first_of("?#"); i != std::string_view::npos) { + throw parse_error(error_code::invalid_nss, + std::string{"'"} + nss[i] + "' inside the NSS"); + } auto parts = split(nss, ':'); if (parts.size() % 2 != 0) { throw parse_error(error_code::invalid_nss, diff --git a/src/nss_parser.hpp b/src/nss_parser.hpp index efd0941..69fbca9 100644 --- a/src/nss_parser.hpp +++ b/src/nss_parser.hpp @@ -42,7 +42,9 @@ bool is_known_component(std::string_view key) noexcept; // (it would drop the component silently), a duplicate known key, and a key // or value with anything outside printable ASCII (0x21–0x7E: control bytes, // whitespace of any kind, non-ASCII bytes). Values are ASCII-trimmed before -// the checks. Mirrors go-mhda's parseNSS. +// the checks. '?' and '#' anywhere in the NSS throw parse_error(invalid_nss): +// parse_urn strips the RFC 8141 r/q/f components first, and an NSS parsed on +// its own must not carry them. Mirrors go-mhda's parseNSS. // // Values may not contain ':'; this holds for every component currently // defined. diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt index 995e633..7c027ce 100644 --- a/tests/data/urn_conformance.txt +++ b/tests/data/urn_conformance.txt @@ -47,3 +47,13 @@ refuse urn invalid_nss urn:mhda:nt:evm:ci:1:xx: refuse urn missing_network_type urn:mhda: refuse nss invalid_nss nt:evm:ci:1:foo accept nss nt:evm:ci:1:wt:web3 urn:mhda:nt:evm:ci:1:wt:web3 + +# '?' and '#' open the RFC 8141 r/q/f components. ParseURN strips them with +# everything after; an NSS passed on its own must not carry them in a value, +# or the URN emitted from it would be truncated on the next parse. +refuse nss invalid_nss nt:evm:ci:1?=q:dt:bip44:dp:m/44'/60'/0'/0/0 +refuse nss invalid_nss nt:evm:ci:1#f +refuse nss invalid_nss nt:evm:ci:1:wi:a?b +refuse nss invalid_nss nt:evm:ci:1:x#y:z +accept urn urn:mhda:nt:evm:ci:1?=q:dt:bip44 urn:mhda:nt:evm:ci:1 +accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0#frag urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 diff --git a/tests/test_chain.cpp b/tests/test_chain.cpp index dc78bde..39171c7 100644 --- a/tests/test_chain.cpp +++ b/tests/test_chain.cpp @@ -120,3 +120,12 @@ TEST_CASE("chain equality is the (nt, ci) identity") { chain other{network_type::ethereum_vm, "10"}; EXPECT_TRUE(a != other); } + +// '?' and '#' open the RFC 8141 r/q/f components; a chain key or NSS carrying +// one in a value would be truncated once embedded in a URN. +TEST_CASE("chain parsers refuse r/q/f delimiters") { + for (const char* s : {"nt:evm:ci:1#a", "nt:evm:ci:1?=q", "nt:evm:ci:a?b"}) { + EXPECT_THROW_CODE(chain::from_key(s), error_code::invalid_nss); + EXPECT_THROW_CODE(chain::from_nss(s), error_code::invalid_nss); + } +} diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp index cd42605..95872be 100644 --- a/tests/test_fuzz.cpp +++ b/tests/test_fuzz.cpp @@ -161,14 +161,24 @@ TEST_CASE("FuzzParseURN: no panic, idempotent on success") { } } -TEST_CASE("FuzzParseNSS: no panic, no half-state on success") { +TEST_CASE("FuzzParseNSS: no panic, emitted URN re-parses to itself") { std::mt19937_64 rng(0xDECADEULL); constexpr int kIterations = 4000; auto check = [&](const std::string& src) { try { auto addr = parse_nss(src); - (void)addr.str(); - (void)addr.nss(); + const std::string once = addr.str(); + if (once != std::string{kPrefix} + addr.nss()) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"str() is not the prefix plus nss(): "} + once}); + return; + } + auto twice = parse_urn(once); + if (twice.str() != once) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"not idempotent: once="} + once + + " twice=" + twice.str() + " input=" + src}); + } } catch (const parse_error&) { // Rejection is fine. } catch (const std::exception& e) { @@ -186,6 +196,8 @@ TEST_CASE("FuzzParseNSS: no panic, no half-state on success") { const std::vector bare_seeds = { "", "n", "nt", "nt:", "nt:evm", "nt:evm:ci:1:ct:60", "nt:evm:ci:1", "nt:evm:ci:1:wt:web3:wi:5f2a8c31", + "nt:evm:ci:1?=q:dt:bip44:dp:m/44'/60'/0'/0/0", + "nt:evm:ci:1:wi:a#b", }; for (const auto& s : bare_seeds) check(s); for (int i = 0; i < kIterations; ++i) { From f78b941b42bed28221d57fd7516e4671c8d405e7 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:43:06 +0300 Subject: [PATCH 09/21] Limit SLIP-10 paths to 255 levels BIP-32 serialises a key's depth in one byte, so a path deeper than 255 levels names a key no wallet can represent; validate_slip10 accepted any depth, and a boundary test pinned a 256-level path as valid. A slip10 path with more than 255 levels now throws invalid_derivation_path, as in go-mhda; the boundary test and the shared derivation-path table cover 255 and 256. --- CHANGELOG.md | 3 +++ SPEC.md | 8 +++++--- src/derivation_path.cpp | 6 +++++- tests/data/dp_conformance.txt | 6 ++++++ tests/test_boundary.cpp | 11 ++++++++--- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8aab29..4bd9261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. byte in a value, so the URN emitted from it was truncated on the next parse (`nt:evm:ci:1:x#y:z` was accepted). The NSS fuzz test now checks that the emitted URN re-parses to itself. +- **A `slip10` path has at most 255 levels**; a deeper one throws + `parse_error(invalid_derivation_path)`. BIP-32 serialises a key's depth + in one byte, so no wallet can represent a deeper key. ### Fixed diff --git a/SPEC.md b/SPEC.md index 91b6f7f..658f4d3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -258,7 +258,7 @@ protocol messages. | bip74 | `m/74'/coin'/account'/charge/index[']` | 5 | sui-keys/src/key_derive.rs (Sui secp256r1) | | bip84 | `m/84'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki | | bip86 | `m/86'/coin'/account'/charge/index[']` | 5 | https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki | -| slip10 | `m(/index['])+` (variable length) | 1+ | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | +| slip10 | `m(/index['])+` (variable length) | 1-255 | https://github.com/satoshilabs/slips/blob/master/slip-0010.md | | cip11 | `m/44'/118'/account'/charge_extra/index[']` | 5 | https://github.com/confio/cosmos-hd-key-derivation-spec | | cip1852 | `m/1852'/1815'/account'/role/index[']` | 5 | https://github.com/cardano-foundation/CIPs/blob/master/CIP-1852/ | | zip32 | `m/32'/133'/account'[/index[']]` | 3 or 4 | https://zips.z.cash/zip-0032 | @@ -280,9 +280,11 @@ exactly as in the template. ### 3.1 Variable-length paths -`slip10` accepts any number of levels and is the right type for chains whose +`slip10` accepts 1 to 255 levels and is the right type for chains whose HD derivation does not fit a fixed shape (Solana, Stellar, Aptos ed25519, -Sui ed25519, NEAR, Ledger TON). +Sui ed25519, NEAR, Ledger TON). The upper bound is BIP-32's: a key's depth +is serialised in one byte, so a deeper path is refused with +`ErrInvalidDerivationPath`. `zip32` accepts both 3-level (`m/32'/133'/account'`) and 4-level (`m/32'/133'/account'/index[']`) forms; both round-trip exactly. diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 8dabdfe..94d6a99 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -116,8 +116,12 @@ bool validate_bip32(const std::vector& lvls) { return true; } +// max_slip10_depth is the deepest SLIP-10 path: BIP-32 serialises a key's +// depth in one byte. +constexpr std::size_t max_slip10_depth = 255; + bool validate_slip10(const std::vector& lvls) { - return !lvls.empty(); // any number of levels >= 1 + return !lvls.empty() && lvls.size() <= max_slip10_depth; } bool validate_cip11(const std::vector& lvls) { diff --git a/tests/data/dp_conformance.txt b/tests/data/dp_conformance.txt index 2056e69..5adba1d 100644 --- a/tests/data/dp_conformance.txt +++ b/tests/data/dp_conformance.txt @@ -464,3 +464,9 @@ refuse zip32 m/032'/133'/0' refuse zip32 m/32'/0133'/0' accept zip32 m/32'/133'/00'/00' m/32'/133'/0'/0' accept slip10 m/044'/0501'/00' m/44'/501'/0' + +# SLIP-10 depth. BIP-32 serialises a key's depth in one byte, so a path has +# at most 255 levels. +accept slip10 m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' +accept slip10 m/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/2147483647' m/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/7/2147483647' +refuse slip10 m/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0'/0' diff --git a/tests/test_boundary.cpp b/tests/test_boundary.cpp index 4f7c774..7d344ba 100644 --- a/tests/test_boundary.cpp +++ b/tests/test_boundary.cpp @@ -186,12 +186,17 @@ TEST_CASE("derivation_path accepts SLIP-10 with single level") { // Huge inputs // --------------------------------------------------------------------------- -TEST_CASE("very long SLIP-10 path (256 levels) round-trips") { +TEST_CASE("SLIP-10 path of 255 levels round-trips, 256 is refused") { + // BIP-32 serialises a key's depth in one byte. std::string path = "m"; - for (int i = 0; i < 256; ++i) path += "/0'"; + for (int i = 0; i < 255; ++i) path += "/0'"; auto dp = derivation_path::parse(derivation_type::slip10, path); - EXPECT_EQ(dp.levels().size(), 256u); + EXPECT_EQ(dp.levels().size(), 255u); EXPECT_EQ(dp.str(), path); + path += "/0'"; + EXPECT_THROW_CODE(derivation_path::parse(derivation_type::slip10, path), + error_code::invalid_derivation_path); + EXPECT_FALSE(validate_derivation_path(derivation_type::slip10, path)); } TEST_CASE("very long chain_id (4 KiB) round-trips") { From a011badee2dedc07c51f7cf16c60850c73e6f922 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:43:29 +0300 Subject: [PATCH 10/21] Document dt:root folding and ZIP-32 without a network SPEC.md promised that a long form round-trips to itself, but an explicit dt:root is folded away: ...:ci:1:dt:root serialises as ...:ci:1. That is the intended behaviour (root is the default and has no path, so a root address keeps one canonical form and one hash, and consumers rely on it); the spec now says so, and the Algorand and TON notes no longer call dt:root the canonical form. zip32 parses, but no network registers it, so parse_urn_strict refuses it everywhere; the spec lists that as a known limitation. Tests pin both: dt:root rows in the shared URN table and a strict-refusal check over every network. Same text as go-mhda. --- CHANGELOG.md | 9 +++++++++ SPEC.md | 19 ++++++++++++++----- tests/data/urn_conformance.txt | 6 ++++++ tests/test_hardening.cpp | 16 ++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd9261..d46a155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,15 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. `parse_error(invalid_derivation_path)`. BIP-32 serialises a key's depth in one byte, so no wallet can represent a deeper key. +### Documentation + +- SPEC.md states that an explicit `dt:root` is folded away (root is the + default and has no path, so a root address has one canonical form and + one hash); the Algorand and TON notes no longer call `dt:root` the + canonical form. It also lists ZIP-32 as a known limitation: `zip32` + parses, but no network registers it, so strict parsing refuses it. Both + behaviours are unchanged and now pinned by tests. + ### Fixed - **Static initialisation and destruction order.** The named constants diff --git a/SPEC.md b/SPEC.md index 658f4d3..8cb110f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -58,7 +58,10 @@ lowercase in the canonical form; free-form values (`ci`, `ap`, `as`, `wt`, Optional components are emitted in canonical output ONLY when explicitly set. A short input form round-trips back to the same short form; a long form -round-trips back to the same long form. +round-trips back to the same long form. The one exception is `dt:root`: +root is the default derivation type and carries no path, so an explicit +`dt:root` is folded away and `...:ci:1:dt:root` serialises as `...:ci:1`. +A root address therefore has a single canonical form (and a single hash). `dp` is present exactly when `dt` is a type other than `root`. A `dp` without `dt` (which makes the address root) or with `dt:root` is refused @@ -229,9 +232,9 @@ CIP-19); Byron-era addresses use base58 and remain on-chain. #### Algorand (`algorand`) Native scheme is non-HD: a 25-word BIP-39-style mnemonic encodes the -32-byte ed25519 seed directly. Canonical URNs use `dt:root`. Some third-party -wallets layer SLIP-10 at `m/44'/283'/account'/0'/0'`; this is accepted but is -not Algorand-canonical. +32-byte ed25519 seed directly. Canonical URNs are root (no `dt`/`dp`). Some +third-party wallets layer SLIP-10 at `m/44'/283'/account'/0'/0'`; this is +accepted but is not Algorand-canonical. #### TON (`ton`) Native scheme is non-HD: a 24-word TON-specific mnemonic (different word list @@ -505,13 +508,19 @@ The C++ port exposes the same pair as `address::marshal_text` / model. A future `dt:substrate` could be added if needed. - **Algorand** and **TON** native schemes are not hierarchical. Their - canonical URN form uses `dt:root` (no `dp`). HD forms (`m/44'/283'/...` / + canonical URN form is root (no `dt`, no `dp`). HD forms (`m/44'/283'/...` / `m/44'/607'/...`) are wallet-specific layering on top, not protocol-canonical. - **Sui** uses the `purpose` field of the derivation path to encode the signature scheme (44'/54'/74'). This is supported via three distinct derivation types (`bip44`, `bip54`, `bip74`). +- **ZIP-32** (`dt:zip32`) parses, but no network registers it: Zcash is not + a registered network type, and its shielded keys use curves (Jubjub, + Pallas) outside the algorithm catalogue. `ParseURNStrict` therefore + refuses every `zip32` URN with `ErrIncompatible`; only lenient parsing + accepts one. + - **Hardening markers** are accepted in three input forms (`'`, `H`, `h`) but always serialised canonically as `'`. Round-trip `Parse(s).String() == s` therefore holds only for inputs already in diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt index 7c027ce..ce5a55f 100644 --- a/tests/data/urn_conformance.txt +++ b/tests/data/urn_conformance.txt @@ -57,3 +57,9 @@ refuse nss invalid_nss nt:evm:ci:1:wi:a?b refuse nss invalid_nss nt:evm:ci:1:x#y:z accept urn urn:mhda:nt:evm:ci:1?=q:dt:bip44 urn:mhda:nt:evm:ci:1 accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0#frag urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0 + +# Root is the default derivation type and carries no path: an explicit +# dt:root is folded away, so a root address has one canonical form. +accept urn urn:mhda:nt:ton:ci:mainnet:dt:root urn:mhda:nt:ton:ci:mainnet +accept urn urn:mhda:nt:algorand:ci:mainnet:ct:283:dt:root:af:base32 urn:mhda:nt:algorand:ci:mainnet:ct:283:af:base32 +accept urn urn:mhda:nt:evm:ci:1:dt:ROOT urn:mhda:nt:evm:ci:1 diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp index 15be51a..d9cb5f4 100644 --- a/tests/test_hardening.cpp +++ b/tests/test_hardening.cpp @@ -224,3 +224,19 @@ TEST_CASE("programmatic values cannot inject components") { EXPECT_THROW_CODE(p.set_type(derivation_type{dt}), error_code::invalid_derivation_type); } } + +// SPEC §12: zip32 parses leniently, but no network registers it, so strict +// parsing refuses it on every network. Mirrors go-mhda. +TEST_CASE("zip32 has no network") { + for (const auto& nt : {network_type::bitcoin, network_type::ethereum_vm, + network_type::avalanche_vm, network_type::tron_vm, + network_type::cosmos, network_type::solana, + network_type::xrp_ledger, network_type::stellar, + network_type::near_protocol, network_type::aptos, + network_type::sui, network_type::cardano, + network_type::algorand, network_type::toncoin}) { + const std::string urn = "urn:mhda:nt:" + nt.str() + ":ci:x:dt:zip32:dp:m/32'/133'/0'"; + EXPECT_NO_THROW(parse_urn(urn)); + EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); + } +} From 0594c7dc3996cecc88a6a326e3bc0201c2e10f15 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:43:59 +0300 Subject: [PATCH 11/21] Pin hash digests at the SHA block boundaries SHA-1 and SHA-256 are hand-written here, and the only reference test hashed one 26-byte string, which never reaches the two-block padding path. Add reference digests for str() of 55, 56, 63, 64, 119, 120, 128 and 1000 bytes (the NSS is 9 bytes shorter, so it covers 55 too), computed with Python's hashlib; go-mhda pins the same values. An off-by-one in the padding length now fails the test at 55 and 119 bytes. --- tests/test_hash.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_hash.cpp b/tests/test_hash.cpp index d01c73f..bfcfaaf 100644 --- a/tests/test_hash.cpp +++ b/tests/test_hash.cpp @@ -1,3 +1,7 @@ +#include +#include +#include + #include "mhda/mhda.hpp" #include "ostream_helpers.hpp" #include "test_framework.hpp" @@ -36,3 +40,61 @@ TEST_CASE("Hash reference values") { EXPECT_EQ(addr.nss_hash256(), std::string{"828f1579eb41e06dcf1a4b32a0223ce57f4be481d79e62735ca64c4a3696a5a2"}); } + +// Digests at the SHA-1/SHA-256 block and padding boundaries: str() of 55, +// 56, 63, 64, 119, 120, 128 and 1000 bytes (the NSS is 9 bytes shorter, so +// it covers 55 too). Computed with Python's hashlib; go-mhda pins the same +// values, so both implementations produce the same identifiers across every +// padding case. +TEST_CASE("Hash reference values at block boundaries") { + struct row { + std::size_t size; + const char* hash; + const char* hash256; + const char* nss_hash; + const char* nss_hash256; + }; + const std::vector rows = { + {55, "7230ec9cac1176541d5e454a5977547849806ef3", + "4bcf74edcd630246d1cb1f03be5dc2deb7cc850270a2d3f279d68166778e2065", + "3c1704aa5f179b7d21a9f726e20473ac2b5786e1", + "c85613e0a6562581d353224545cbebc3710ddb496d4b03baddd435fa311b7bbb"}, + {56, "7c08382a5672e118ef014b54c8d1b42e7b6cb90b", + "a176aa8ff4ccdcb7b05e3e39440cc40b8b17219910f3081c062045abbf331255", + "8f17a0079072eb355e316ebdbb73aa4328762ff7", + "242f19fedc19e36ab9c4378b45922e731269ea04c325b58c4a90d4389ffce482"}, + {63, "6b6034fd2e44f4ad8ea9da946a6915f017966ead", + "0be4d717499d1b2a081c60cf64f0c59b5e09a1a0da1e65c497c83e839caef655", + "4b8360e09c0db0f1c423342d03db990ee4795695", + "0bd7a04f6ca2b30eca17273961fa78db6f345e654ed80ac78a85d780d73d0267"}, + {64, "c5b9fb4f88ee7f77e14566483838fe03eb5f722a", + "be3e95721333ec68988eedb3b6c37c7c48ee30211c3bef96900609e8f1dd3546", + "fba8bdfdfb761ca75bc1a38886a7219cba376adc", + "713ea1b363b46219865aded589c56341e7d0e63c9278e7e11ae154f05883d1c5"}, + {119, "91f3a5706cdf32541f6c2d45ea4280399da43847", + "d04d2a8a7f6df456cada8d263a746f0d21e4b600ef7bdcea4f6e645c17d94914", + "94285bd9afc8f49eb2059992f3660aee22e76063", + "d3398acc7f987d3d3c90acd2e4ef958bd7053a8a88e4b2d51b5774b0fb03cfdc"}, + {120, "8f7e2ab8a8a0c101e299154982f0e262bf43b7d0", + "a4ed7ff3b82d6f7fa7a322ca72971a2b1e6474a5d08475599bc965282acb49c7", + "7eb1dc21b8685d3b9dbc9c2cbb9240acc90a2660", + "aba04d6e4a96a9f662bb9ac433ba50f2c34d7991aee8cb2787ab91bc4228e88a"}, + {128, "56e36e13d0581067945cd12955e3ce4b21967a48", + "b6fb21cc83b07ab98648ec0b1bb04a3d79cd31d231cc07303d24ce73145a46ed", + "c792663db1797615b2433d56b96bf73d859a3e6f", + "2c04bb624a604bd96c5b0e4b9e1006bbe898f134b60a40a1ed81d63e03897ea7"}, + {1000, "f3acba196b25a975d6989beef6693337b5987f26", + "7d57f53b9e4785b1735151906fd63ccb70547a8a2292986b613cf23db7b0f3d7", + "d16eb7a3991c6ace27d58b3e80e617567cfd242b", + "2e7dd698a37e3fb7660b19bcad20b60677bd1e7f01d79870daa09a8a1f7269d8"}, + }; + for (const auto& r : rows) { + const std::string urn = "urn:mhda:nt:evm:ci:" + std::string(r.size - 19, 'x'); + const auto addr = parse_urn(urn); + EXPECT_EQ(addr.str().size(), r.size); + EXPECT_EQ(addr.hash(), std::string{r.hash}); + EXPECT_EQ(addr.hash256(), std::string{r.hash256}); + EXPECT_EQ(addr.nss_hash(), std::string{r.nss_hash}); + EXPECT_EQ(addr.nss_hash256(), std::string{r.nss_hash256}); + } +} From ea6fb96f84f7fdc6a5c2e21d411021d8a4da59ea Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 11:45:47 +0300 Subject: [PATCH 12/21] CI: sanitizer and warnings-as-errors jobs CI built and tested the Release/Debug matrix only, while the README promises a clean run under ASan/UBSan and under a strict warning set. Add a job that builds with -fsanitize=address,undefined and runs the suite with halt_on_error, and a job that builds with the README's warning set as errors. The warnings job is pinned to ubuntu-22.04 (GCC 11, the compiler the README lists as verified); the same flags are also clean with GCC 12 and Clang 14. The workflow runs with read-only permissions. README test counts and the 1.2.0 CHANGELOG test notes are brought up to date (152 cases). --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 10 +++++++++- README.md | 7 ++++--- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e3666..0cb2f07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: [push, pull_request] +permissions: + contents: read + jobs: build: strategy: @@ -18,3 +21,36 @@ jobs: run: cmake --build build --parallel - name: Test run: ctest --test-dir build --output-on-failure + + sanitizers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: > + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + "-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all" + "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined" + - name: Build + run: cmake --build build --parallel + - name: Test + env: + ASAN_OPTIONS: halt_on_error=1:detect_leaks=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: ctest --test-dir build --output-on-failure + + # The warning set the README promises, as errors. Pinned to the GCC the + # README lists as verified (Ubuntu 22.04, GCC 11): a newer compiler may add + # warnings that need a deliberate look rather than a red build. + warnings: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: > + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug + "-DCMAKE_CXX_FLAGS=-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wold-style-cast -Wnon-virtual-dtor -Wcast-align -Wunused -Woverloaded-virtual -Wnull-dereference -Wdouble-promotion -Wformat=2 -Werror" + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CHANGELOG.md b/CHANGELOG.md index d46a155..6d575c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,7 +116,15 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. shared verbatim with go-mhda (`testdata/dp_conformance.txt`) and run by both suites, standalone and inside a URN, so a path one implementation refuses, the other refuses too. The boundary test and the fuzz seed that - pinned `4294967295` now sit at the new bound; 145 test cases total. + pinned `4294967295` now sit at the new bound. +- `tests/data/urn_conformance.txt`: a URN-level table shared verbatim with + go-mhda, run through `parse_urn` / `parse_nss` with the expected error + code, for the parser changes above. +- `tests/test_static_init.cpp` uses the library from a global constructor + and a global destructor; hash digests are pinned at the SHA block and + padding boundaries (55 to 1000 bytes). +- CI adds an ASan/UBSan job and a `-Werror` job with the README's warning + set, and runs with read-only permissions. 152 test cases total. ## [1.1.0] — 2026-07-04 diff --git a/README.md b/README.md index 878b934..2b7f343 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,11 @@ urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31 - Version: **1.2.0** - Standard: **C++17**, no external runtime dependencies -- Tests: **145** unit + fuzz-equivalent stress cases (≈11 000 randomised +- Tests: **152** unit + fuzz-equivalent stress cases (≈11 000 randomised iterations), passing under `-fsanitize=address,undefined,leak` - Compilers verified: GCC 11.4 (Ubuntu 22.04), Clang 14 (when libstdc++ is - available); the CI matrix runs Linux + macOS, Release + Debug + available); the CI matrix runs Linux + macOS, Release + Debug, plus an + ASan/UBSan job and a `-Werror` job with the warning set below - Warning policy: clean under `-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Werror` - Binary stability is not yet guaranteed across minor versions; 1.2.0 @@ -218,7 +219,7 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. ## Testing & validation -- 145 unit + fuzz-equivalent test cases. +- 152 unit + fuzz-equivalent test cases. - Fuzz harness runs ≈11 000 randomised mutations of the historical Go-fuzz seed corpus per execution (URN, NSS and derivation-path entry points). Contracts verified: no exception other than `parse_error`/`std::invalid_argument`, From 87982f52230da8c111e2f225054e7ef0e3f35600 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 12:56:17 +0300 Subject: [PATCH 13/21] Keep levels() and str() of a path in agreement from_levels kept the levels it was given while str() printed the type's template: a BIP-44 path built from 49'/60'/0/0/0 printed m/44'/60'/0'/0/0 but returned purpose 49' and an unhardened account from levels(), which is what a wallet derives from. Too few levels (none for SLIP-10) or an index of 2^31 gave a URN that does not parse, and the five-argument constructor accepted a charge of 7 or an account of 2^31. Both constructors now return the path parse() gives back for their own str() and throw parse_error(invalid_derivation_path) unless it has exactly the requested levels. The boundary tests that pinned the old results for an empty SLIP-10 and a two-level BIP-44 path now expect the refusal, and the fuzz tests check that a re-parsed path has the same levels. Mirrors go-mhda. --- CHANGELOG.md | 11 ++++++ SPEC.md | 11 ++++++ include/mhda/derivation_path.hpp | 10 +++++- src/derivation_path.cpp | 18 +++++++++- tests/CMakeLists.txt | 1 + tests/test_boundary.cpp | 26 +++++++------- tests/test_fuzz.cpp | 12 +++++++ tests/test_state.cpp | 61 ++++++++++++++++++++++++++++++++ 8 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 tests/test_state.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d575c1..ff72825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,17 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. ### Fixed +- **`levels()` and `str()` cannot disagree.** `from_levels` kept any + levels it was given while `str()` printed the type's template: a BIP-44 + path built from `49'/60'/0/0/0` printed `m/44'/60'/0'/0/0` but returned + purpose 49' and an unhardened account from `levels()`, which is what a + wallet derives from. Too few levels (or none for SLIP-10) or an index of + 2^31 produced a URN that does not parse, and the five-argument + constructor accepted a charge of 7 or an account of 2^31. Both now + return exactly the path `parse()` gives back for their own `str()`, and + throw `parse_error(invalid_derivation_path)` otherwise. Two boundary + tests that pinned the old results now expect the refusal. Mirrors + go-mhda. - **Static initialisation and destruction order.** The named constants (`network_type::bitcoin`, `algorithm::secp256k1`, `format::hex`, `derivation_type::root`, ...) were defined in the library's `.cpp` files diff --git a/SPEC.md b/SPEC.md index 8cb110f..3e57393 100644 --- a/SPEC.md +++ b/SPEC.md @@ -298,6 +298,17 @@ Regardless of derivation type, the canonical level-by-level view is exposed via `DerivationPath.Levels()`. For BIP-family schemes the BIP-44 shortcut fields (`Coin`, `Account`, `Charge`, `AddressIndex`) are also populated. +`Levels()` and `String()` always describe the same path. A path built in code +(`NewDerivationPath`, `NewDerivationPathFromLevels`) must be one the parser +would produce from its own `String()`: levels that do not fit the type (a +BIP-44 path with purpose `49'`, an unhardened account, too few levels, a +SLIP-10 path with none) or an index of 2^31 or more panic with +`ErrInvalidDerivationPath`. `Levels()` returns a copy, and `NewAddress` keeps +its own copies of the chain and the path it is given. (C++: the +`derivation_path` constructor and `from_levels` throw +`parse_error(invalid_derivation_path)`; `levels()` returns a const reference +and `address` holds its chain and path by value.) + ## 4. Algorithms | `aa` | Notes | diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index 7b1643b..1822e63 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -53,7 +53,9 @@ class derivation_path { // std::invalid_argument (mirroring the panic in the Go implementation). // An unregistered derivation type throws // parse_error(invalid_derivation_type): the type is written verbatim - // into the URN. + // into the URN. The result is the path parse() gives back for its own + // str(); values that do not form a valid path (charge 7 in a BIP-44 + // path, an index of 2^31) throw parse_error(invalid_derivation_path). derivation_path(derivation_type dt, coin_type coin, account_index account, @@ -64,6 +66,12 @@ class derivation_path { // SLIP-10 and a convenient alternative for any scheme. For fixed-shape // schemes the shortcut fields are populated from the levels. An // unregistered derivation type throws parse_error(invalid_derivation_type). + // The result is the path parse() gives back for its own str(), and it + // must have exactly the given levels: levels that do not form a path of + // the type (purpose 49' for bip44, an unhardened account, too few levels, + // none for slip10, an index of 2^31) throw + // parse_error(invalid_derivation_path), so levels() and str() never + // disagree. static derivation_path from_levels(derivation_type dt, std::vector levels); diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 94d6a99..548e52c 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -172,6 +172,21 @@ bool validate_levels_for(const derivation_type& dt, const std::vector return false; } +// reparsed returns the path the parser gives back for dp.str() and throws +// parse_error(invalid_derivation_path) unless it has exactly the given +// levels. A constructed path is then identical to a parsed one: its URN +// parses, and levels() never disagrees with str() (a BIP-44 path given +// purpose 49', an unhardened account, too few levels or an index of 2^31 is +// refused). Mirrors go-mhda. +derivation_path reparsed(const derivation_path& dp, const std::vector& levels) { + derivation_path out = derivation_path::parse(dp.type(), dp.str()); + if (out.levels() != levels) { + throw parse_error(error_code::invalid_derivation_path, + "levels do not form a " + dp.type().str() + " path"); + } + return out; +} + } // namespace bool validate_derivation_path(const derivation_type& dt, std::string_view path) { @@ -202,6 +217,7 @@ derivation_path::derivation_path(derivation_type dt, } has_index_ = (type_ != derivation_type::root); rebuild_levels(); + *this = reparsed(*this, levels_); } derivation_path derivation_path::from_levels(derivation_type dt, @@ -214,7 +230,7 @@ derivation_path derivation_path::from_levels(derivation_type dt, dp.type_ = std::move(dt); dp.levels_ = std::move(levels); dp.populate_shortcuts_from_levels(); - return dp; + return reparsed(dp, dp.levels_); } derivation_path derivation_path::parse(derivation_type dt, std::string_view path) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bdb18f0..78cb027 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(mhda_tests test_index_range.cpp test_static_init.cpp test_urn_conformance.cpp + test_state.cpp ) target_link_libraries(mhda_tests PRIVATE mhda) target_include_directories(mhda_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/test_boundary.cpp b/tests/test_boundary.cpp index 7d344ba..d8db74e 100644 --- a/tests/test_boundary.cpp +++ b/tests/test_boundary.cpp @@ -359,19 +359,19 @@ TEST_CASE("self-assignment via reference leaves address unchanged") { EXPECT_EQ(a.str(), before); } -TEST_CASE("from_levels with zero levels for SLIP10 produces empty path") { - auto dp = derivation_path::from_levels(derivation_type::slip10, {}); - EXPECT_EQ(dp.str(), std::string{"m"}); - EXPECT_FALSE(dp.has_index()); -} - -TEST_CASE("from_levels under-populated for BIP44 leaves shortcuts at zero") { - auto dp = derivation_path::from_levels(derivation_type::bip44, { - {44, true}, {60, true}, // only 2 levels, well below the 5 expected - }); - EXPECT_EQ(dp.coin(), 0u); - EXPECT_EQ(dp.account(), 0u); - EXPECT_FALSE(dp.has_index()); +TEST_CASE("from_levels with zero levels for SLIP10 is refused") { + // "m" alone is no SLIP-10 path: the parser refuses it, so does from_levels. + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type::slip10, {}), + error_code::invalid_derivation_path); +} + +TEST_CASE("from_levels under-populated for BIP44 is refused") { + // Two levels are no BIP-44 path; str() would print the zero template + // m/44'/0'/0'/0/0 while levels() held 44'/60'. + EXPECT_THROW_CODE(derivation_path::from_levels(derivation_type::bip44, { + {44, true}, {60, true}, + }), + error_code::invalid_derivation_path); } TEST_CASE("hash and nss_hash do not crash on uninitialised address") { diff --git a/tests/test_fuzz.cpp b/tests/test_fuzz.cpp index 95872be..96e03d2 100644 --- a/tests/test_fuzz.cpp +++ b/tests/test_fuzz.cpp @@ -144,6 +144,12 @@ TEST_CASE("FuzzParseURN: no panic, idempotent on success") { std::string{"not idempotent: once="} + once + " twice=" + twice.str() + " input=" + src}); } + // The levels a wallet derives from must survive the round trip. + if (addr.path().has_value() != twice.path().has_value() || + (addr.path() && addr.path()->levels() != twice.path()->levels())) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"path changed on re-parse: "} + once + " input=" + src}); + } } catch (const parse_error&) { // Rejected inputs are fine; the contract is just "no crash". } catch (const std::exception& e) { @@ -222,6 +228,12 @@ TEST_CASE("FuzzDerivationPath: no panic, idempotent on success") { + " twice=" + dp2.str() + " input dt=" + dt_str + " path=" + path}); } + // str() and levels() describe the same path. + if (dp2.levels() != dp.levels()) { + mhda_failures.push_back({__FILE__, __LINE__, + std::string{"path changed on re-parse: "} + once + + " input dt=" + dt_str + " path=" + path}); + } // No accepted level may carry an index of 2^31 or more. for (const auto& lvl : dp.levels()) { if (lvl.index > 0x7FFFFFFFu) { diff --git a/tests/test_state.cpp b/tests/test_state.cpp new file mode 100644 index 0000000..58074b6 --- /dev/null +++ b/tests/test_state.cpp @@ -0,0 +1,61 @@ +// State of derivation_path and address across construction, re-parsing and +// type changes, mirroring state_test.go of the Go reference: a constructed +// path is exactly what the parser gives back for its own str(), parsing +// replaces a path as a whole, and a type change drops the old path. + +#include +#include +#include +#include +#include + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +using namespace mhda; + +TEST_CASE("constructors make only parseable paths") { + const std::vector>> refused = { + {"bip44 purpose 49", [] { derivation_path::from_levels(derivation_type::bip44, + {{49, true}, {60, true}, {0, true}, {0, false}, {0, false}}); }}, + {"bip44 soft account", [] { derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}, {0, false}, {0, false}, {0, false}}); }}, + {"bip44 two levels", [] { derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}}); }}, + {"slip10 no levels", [] { derivation_path::from_levels(derivation_type::slip10, {}); }}, + {"slip10 index 2^31", [] { derivation_path::from_levels(derivation_type::slip10, + {{0x80000000u, false}}); }}, + {"root with a level", [] { derivation_path::from_levels(derivation_type::root, + {{0, true}}); }}, + {"bip44 account 2^31", [] { derivation_path(derivation_type::bip44, 60, 0x80000000u, 0, + address_index{}); }}, + {"bip44 charge 7", [] { derivation_path(derivation_type::bip44, 60, 0, 7, address_index{}); }}, + {"bip32 index 4294967295", [] { derivation_path(derivation_type::bip32, 0, 0, 0, + address_index{4294967295u, false}); }}, + }; + for (const auto& r : refused) { + bool code_ok = false; + try { + r.second(); + } catch (const parse_error& e) { + code_ok = e.code() == error_code::invalid_derivation_path; + } + if (!code_ok) { + mhda_failures.push_back({__FILE__, __LINE__, + r.first + ": expected parse_error(invalid_derivation_path)"}); + } + } + + const auto dp = derivation_path::from_levels(derivation_type::bip44, + {{44, true}, {60, true}, {0, true}, {0, false}, {5, false}}); + const auto parsed = derivation_path::parse(derivation_type::bip44, "m/44'/60'/0'/0/5"); + EXPECT_EQ(dp.str(), parsed.str()); + EXPECT_TRUE(dp.levels() == parsed.levels()); + EXPECT_EQ(dp.coin(), parsed.coin()); + EXPECT_EQ(dp.account(), parsed.account()); + EXPECT_EQ(dp.charge(), parsed.charge()); + EXPECT_EQ(dp.index(), parsed.index()); + // BIP-32 has no coin level: the constructor drops the coin like the parser. + EXPECT_EQ(derivation_path(derivation_type::bip32, 60, 0, 0, address_index{}).coin(), 0u); +} From ee03dec7e5c01eb9ac55667ea98ff7808ca78c45 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 12:57:48 +0300 Subject: [PATCH 14/21] Replace the derivation path as a whole, never field by field set_type and address::set_derivation_type changed the type and kept the old path: a bip44 address switched to zip32 serialised as dt:zip32:dp:m/32'/133'/3'/9, a valid URN naming a key nobody gave, and a SLIP-10 path parsed after it kept the BIP-44 coin, account and charge. set_derivation_type followed by a failing set_derivation_path left that mixed address behind, and a bad_alloc inside parse_path left str() and levels() disagreeing. A new type now clears the path; until one is set, str() of the path is empty, the URN carries dt without dp (so it does not parse) and validate / marshal_text throw invalid_derivation_path. parse_path parses into a fresh path and move-assigns it, so it replaces the old path only on success (strong guarantee). address::set_derivation(dt, dp) sets both at once and changes nothing on error; the URN parser uses it. Mirrors go-mhda. --- CHANGELOG.md | 13 +++++++ SPEC.md | 10 +++++ include/mhda/address.hpp | 14 +++++++ include/mhda/derivation_path.hpp | 13 +++++-- src/address.cpp | 44 ++++++++++++++++------ src/derivation_path.cpp | 21 ++++++++++- src/parser.cpp | 4 +- tests/test_state.cpp | 63 ++++++++++++++++++++++++++++++++ 8 files changed, 163 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff72825..558d56d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,19 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. ### Fixed +- **No stale or mixed derivation state.** `set_type` and + `address::set_derivation_type` changed the type and kept the old path, so + a bip44 address switched to zip32 serialised as + `dt:zip32:dp:m/32'/133'/3'/9`, a valid URN naming a key nobody gave, and + a SLIP-10 path parsed after it kept the BIP-44 coin, account and charge. + `set_derivation_type` followed by a failing `set_derivation_path` left + that mixed address behind, and a `bad_alloc` inside `parse_path` left + `str()` and `levels()` disagreeing. A new type now clears the path; + until one is set the URN carries `dt` without `dp` (and does not parse), + and `validate` / `marshal_text` throw `invalid_derivation_path`. + `parse_path` parses into a fresh path and replaces the old one only on + success. The new `address::set_derivation(dt, dp)` sets both at once and + changes nothing on error; the URN parser uses it. Mirrors go-mhda. - **`levels()` and `str()` cannot disagree.** `from_levels` kept any levels it was given while `str()` printed the type's template: a BIP-44 path built from `49'/60'/0/0/0` printed `m/44'/60'/0'/0/0` but returned diff --git a/SPEC.md b/SPEC.md index 3e57393..397ee47 100644 --- a/SPEC.md +++ b/SPEC.md @@ -375,6 +375,16 @@ as if rewritten in long form. ROOT is always accepted regardless of network. +An address built in code can have a derivation type without a path: +`SetDerivationType` with a new type drops the old path, which belongs to the +old scheme. Such an address serialises with `dt` but no `dp`, so its URN does +not parse, and `Validate` / `MarshalText` refuse it with +`ErrInvalidDerivationPath`. `SetDerivation(dt, dp)` sets both at once and +changes nothing unless both are valid. `ParsePath` replaces the whole path, +or leaves it unchanged on error. (C++: `address::set_derivation_type`, +`address::set_derivation`, `derivation_path::set_type` and +`derivation_path::parse_path`, which gives the strong exception guarantee.) + ### 6.3 Round-trip semantics The reference implementation guarantees: diff --git a/include/mhda/address.hpp b/include/mhda/address.hpp index bc61d7a..1e14125 100644 --- a/include/mhda/address.hpp +++ b/include/mhda/address.hpp @@ -71,6 +71,11 @@ class address { // set_derivation_type sets the path's derivation type; an empty string // resolves to ROOT. Allocates a fresh derivation_path if none was attached. + // A type other than the current one drops the current path, which + // belongs to the old scheme: until set_derivation_path sets a new one + // the address has no path, its URN carries dt without dp, and validate / + // marshal_text throw parse_error(invalid_derivation_path). Setting the + // current type again keeps the path. Use set_derivation to change both. void set_derivation_type(std::string_view dt); // set_derivation_path validates and applies the textual path. A root @@ -79,6 +84,11 @@ class address { // parse_error. void set_derivation_path(std::string_view dp); + // set_derivation sets the derivation type and path together, as a parsed + // URN does: both are validated first, and the address changes only if + // both are valid. An empty dt means ROOT, which takes an empty dp. + void set_derivation(std::string_view dt, std::string_view dp); + // set_coin_type sets the chain's optional coin-type metadata from a // decimal or "0x"-prefixed hex string. An empty string clears the // metadata. Throws parse_error on invalid input. @@ -138,6 +148,10 @@ class address { std::string nss_hash256() const; private: + // check_path_set throws parse_error(invalid_derivation_path) for a + // derivation type set without a path (see set_derivation_type). + void check_path_set() const; + chain chain_; std::optional path_; algorithm algorithm_; diff --git a/include/mhda/derivation_path.hpp b/include/mhda/derivation_path.hpp index 1822e63..b8f366c 100644 --- a/include/mhda/derivation_path.hpp +++ b/include/mhda/derivation_path.hpp @@ -95,16 +95,21 @@ class derivation_path { std::string str() const; // parse_path replaces the contents of this path with the result of parsing - // the given string under the path's current derivation type. Throws - // parse_error on failure. The derivation type must already be set (via - // construction or set_type); for ROOT, path must be empty. + // the given string under the path's current derivation type. The whole + // path is replaced, so nothing of a previous path survives; on failure it + // throws parse_error and leaves the path unchanged. The derivation type + // must already be set (via construction or set_type); for ROOT, path must + // be empty. void parse_path(std::string_view path); // set_type throws parse_error(invalid_derivation_type) for an - // unregistered type. + // unregistered type. A type other than the current one clears the path, + // which belongs to the old scheme: str() is empty until parse_path sets a + // new one. Setting the current type again keeps the path. void set_type(const derivation_type& dt); private: + void parse_fresh(std::string_view path); void rebuild_levels(); void populate_shortcuts_from_levels(); bool fixed_prefix(std::uint32_t& purpose, std::uint32_t& coin) const; diff --git a/src/address.cpp b/src/address.cpp index 36672bb..6264aa5 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -56,17 +56,32 @@ format address::resolved_format() const { void address::set_derivation_type(std::string_view dt) { auto trimmed = detail::trim(dt); auto lowered = detail::to_lower(trimmed); - if (!path_) path_.emplace(); - if (lowered.empty()) { - path_->set_type(derivation_type::root); - return; + derivation_type next = derivation_type::root; + if (!lowered.empty()) { + next = derivation_type{lowered}; + if (!next.is_valid()) { + throw parse_error(error_code::invalid_derivation_type, + std::string{"\""} + lowered + "\""); + } } - derivation_type next{lowered}; - if (!next.is_valid()) { - throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + lowered + "\""); + if (!path_) path_.emplace(); + path_->set_type(next); // a new type drops the old path +} + +void address::set_derivation(std::string_view dt, std::string_view dp) { + address scratch; + scratch.set_derivation_type(dt); + scratch.set_derivation_path(dp); + path_ = std::move(scratch.path_); +} + +void address::check_path_set() const { + if (path_ && !path_->type().empty() && path_->type() != derivation_type::root && + path_->levels().empty()) { + throw parse_error(error_code::invalid_derivation_path, + std::string{"derivation type \""} + path_->type().str() + + "\" is set without a path"); } - path_->set_type(next); } void address::set_derivation_path(std::string_view dp) { @@ -178,11 +193,16 @@ std::string address::nss() const { } // Derivation domain — present when not ROOT and a non-empty type is set. + // A type set without a path yet is emitted without dp, so the URN fails + // to parse rather than name another key. if (path_ && !path_->type().empty() && path_->type() != derivation_type::root) { out += ":dt:"; out += path_->type().str(); - out += ":dp:"; - out += path_->str(); + const std::string p = path_->str(); + if (!p.empty()) { + out += ":dp:"; + out += p; + } } // Address-format metadata — emitted only when explicitly set. @@ -225,6 +245,7 @@ std::string address::marshal_text() const { if (chain_.network().empty()) { throw parse_error(error_code::uninitialized_address); } + check_path_set(); return str(); } @@ -237,6 +258,7 @@ void address::validate() const { if (nt.empty()) { throw parse_error(error_code::uninitialized_address); } + check_path_set(); if (!detail::network_is_registered(nt)) { throw parse_error(error_code::incompatible, std::string{"unknown network type \""} + nt.str() + "\""); diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 548e52c..26d3cfe 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -249,10 +249,24 @@ void derivation_path::set_type(const derivation_type& dt) { throw parse_error(error_code::invalid_derivation_type, std::string{"\""} + dt.str() + "\""); } - type_ = dt; + if (dt == type_) return; + // The old levels and shortcuts belong to the old scheme. + derivation_path fresh; + fresh.type_ = dt; + *this = std::move(fresh); } void derivation_path::parse_path(std::string_view path) { + // Parse into a fresh path and replace this one only on success: nothing + // of the previous path survives, and an error (bad_alloc included) + // leaves it unchanged. + derivation_path fresh; + fresh.type_ = type_; + fresh.parse_fresh(path); + *this = std::move(fresh); +} + +void derivation_path::parse_fresh(std::string_view path) { if (!type_.is_valid()) { throw parse_error(error_code::invalid_derivation_type, std::string{"\""} + type_.str() + "\""); @@ -445,7 +459,10 @@ std::string format_levels(const std::vector& lvls) { } // namespace std::string derivation_path::str() const { - if (type_ == derivation_type::root) return ""; + // Root has no levels; a path whose type is set but which has none yet + // (none was parsed or constructed) is empty too, rather than the zero + // shortcuts of its type. + if (levels_.empty()) return ""; if (type_ == derivation_type::slip10) return format_levels(levels_); if (type_ == derivation_type::bip32) { std::string out = "m/"; diff --git a/src/parser.cpp b/src/parser.cpp index 6498732..5cb2cb1 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -22,8 +22,8 @@ address parse_address_from_components(const std::unordered_mapsecond; }; - addr.set_derivation_type(get(detail::comp_derivation_type)); - addr.set_derivation_path(get(detail::comp_derivation_path)); + addr.set_derivation(get(detail::comp_derivation_type), + get(detail::comp_derivation_path)); addr.set_address_algorithm(get(detail::comp_address_algorithm)); addr.set_address_format(get(detail::comp_address_format)); addr.set_address_prefix(get(detail::comp_address_prefix)); diff --git a/tests/test_state.cpp b/tests/test_state.cpp index 58074b6..3fc8249 100644 --- a/tests/test_state.cpp +++ b/tests/test_state.cpp @@ -59,3 +59,66 @@ TEST_CASE("constructors make only parseable paths") { // BIP-32 has no coin level: the constructor drops the coin like the parser. EXPECT_EQ(derivation_path(derivation_type::bip32, 60, 0, 0, address_index{}).coin(), 0u); } + +TEST_CASE("re-parsing leaves no stale state") { + // ZIP-32: a 3-level path after a 4-level one has no index left over. + auto dp = derivation_path::parse(derivation_type::zip32, "m/32'/133'/0'/5"); + dp.parse_path("m/32'/133'/1'"); + EXPECT_EQ(dp.str(), std::string{"m/32'/133'/1'"}); + EXPECT_EQ(dp.levels().size(), 3u); + EXPECT_EQ(dp.index(), address_index{}); + + // SLIP-10 after BIP-44: the type change drops the BIP-44 shortcuts. + auto sl = derivation_path::parse(derivation_type::bip44, "m/44'/60'/5'/1/9"); + sl.set_type(derivation_type::slip10); + EXPECT_TRUE(sl.levels().empty()); + EXPECT_EQ(sl.str(), std::string{}); + sl.parse_path("m/0'"); + EXPECT_EQ(sl.coin(), 0u); + EXPECT_EQ(sl.account(), 0u); + EXPECT_EQ(sl.charge(), 0u); + EXPECT_EQ(sl.index(), address_index{}); + + // The same type keeps the path; a failed parse changes nothing. + auto keep = derivation_path::parse(derivation_type::bip44, "m/44'/60'/3'/1/9"); + keep.set_type(derivation_type::bip44); + EXPECT_THROW_CODE(keep.parse_path("m/44'/60'/4'/0/2147483648"), + error_code::invalid_derivation_path); + EXPECT_EQ(keep.str(), std::string{"m/44'/60'/3'/1/9"}); + EXPECT_EQ(keep.account(), 3u); +} + +// A new derivation type invalidates the old path. Until a path is set the +// address names no path at all: its URN carries dt without dp (and so does +// not parse), and validate / marshal_text refuse it. +TEST_CASE("a derivation type change drops the path") { + const std::string bip44 = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/3'/1/9"; + auto a = parse_urn(bip44); + a.set_derivation_type("zip32"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:zip32"}); + EXPECT_TRUE(a.path()->levels().empty()); + EXPECT_THROW_CODE(a.validate(), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(a.marshal_text(), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(parse_urn(a.str()), error_code::invalid_derivation_path); + a.set_derivation_path("m/32'/133'/7'"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:zip32:dp:m/32'/133'/7'"}); + + // The same type, in any spelling, keeps the path. + auto b = parse_urn(bip44); + b.set_derivation_type(" BIP44 "); + EXPECT_EQ(b.str(), bip44); +} + +// set_derivation validates the type and the path before changing anything. +TEST_CASE("set_derivation is atomic") { + const std::string bip44 = "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/3'/1/9"; + auto a = parse_urn(bip44); + EXPECT_THROW_CODE(a.set_derivation("slip10", "garbage"), error_code::invalid_derivation_path); + EXPECT_THROW_CODE(a.set_derivation("bogus", "m/0'"), error_code::invalid_derivation_type); + EXPECT_EQ(a.str(), bip44); + a.set_derivation("slip10", "m/0'/1'"); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1:dt:slip10:dp:m/0'/1'"}); + EXPECT_EQ(a.path()->account(), 0u); + a.set_derivation("", ""); + EXPECT_EQ(a.str(), std::string{"urn:mhda:nt:evm:ci:1"}); +} From 1c47b9795a40c009b9e5c2863f6b2485616046da Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 12:59:13 +0300 Subject: [PATCH 15/21] Refuse underivable curve, purpose and format pairs in strict mode validate checked the algorithm, the format and the derivation type each against the network on its own. It accepted SLIP-10 ed25519 paths with soft levels (ed25519 has no soft derivation), bip44 with ed25519 on XRPL, NEAR and Aptos, Sui bip54 with ed25519 or secp256r1 (the purpose encodes the scheme), a Bitcoin bip84 path with af:p2pkh, and Cosmos bip44 with coin 118', which is the cip11 path under a second name. The compatibility matrix now binds derivation types to their curve on Sui, Aptos and NEAR and Bitcoin purposes to their scripts, and validate also refuses, with incompatible: an ed25519 path with an unhardened level, except cip1852 (BIP32-Ed25519); a derivation type with another curve than its own; an explicit Bitcoin format that does not match the purpose; and Cosmos bip44 with coin 118'. A Bitcoin URN without af stays valid. Mirrors go-mhda. The shared URN table gains strict rows (the runner learns the strict kind and the incompatible code). Tests that pinned now-refused pairs move to matching purposes, and the XRPL ed25519 example becomes the root form its family-seed keys have. --- CHANGELOG.md | 17 ++++++++ SPEC.md | 36 ++++++++++++++--- src/address.cpp | 42 +++++++++++++++++--- src/compatibility.cpp | 38 ++++++++++++++++++ src/compatibility.hpp | 10 +++++ tests/data/urn_conformance.txt | 71 +++++++++++++++++++++++++++++++--- tests/test_urn_conformance.cpp | 7 +++- tests/test_validate.cpp | 7 ++-- 8 files changed, 206 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558d56d..2f1235f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,23 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. parses, but no network registers it, so strict parsing refuses it. Both behaviours are unchanged and now pinned by tests. +- **Strict validation refuses curve, purpose and format combinations no + wallet can derive.** `validate` checked the algorithm, the format and the + derivation type each on its own, so it accepted SLIP-10 ed25519 paths + with soft levels (`nt:solana:...:dp:m/44'/501'/0/0`; ed25519 has no soft + derivation), `bip44` with ed25519 on XRPL, NEAR and Aptos, Sui `bip54` + with ed25519 or secp256r1, a Bitcoin `bip84` path with `af:p2pkh`, and + Cosmos `bip44` with coin `118'`, which is the `cip11` path again. + `parse_urn_strict` / `validate` now throw `parse_error(incompatible)` + for: an ed25519 path with an unhardened level (except `cip1852`, which + is BIP32-Ed25519); a derivation type with another curve than its own on + Sui (`slip10` ed25519, `bip54` secp256k1, `bip74` secp256r1), Aptos and + NEAR (`slip10` ed25519, `bip44` secp256k1); an explicit Bitcoin format + that does not match the purpose (`bip44` p2pkh, `bip49` p2sh, `bip84` + p2wpkh or bech32, `bip86` p2tr or bech32m); and Cosmos `bip44` with coin + `118'`. A Bitcoin URN without `af` stays valid; SPEC.md no longer claims + strict mode requires it. Lenient parsing is unchanged. Mirrors go-mhda. + ### Fixed - **No stale or mixed derivation state.** `set_type` and diff --git a/SPEC.md b/SPEC.md index 397ee47..a275aa0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -172,9 +172,11 @@ keeps the family name rather than any flagship chain's name. ### 2.1 Per-network notes #### Bitcoin (`bitcoin`) -Multiple legitimate scripts; format must be specified explicitly under strict -validation. `ap` is conventionally `1` (P2PKH), `3` (P2SH), `bc1q` (bech32), -`bc1p` (bech32m / Taproot). +Multiple legitimate scripts, so there is no default format and `af` may be +omitted. When given, it must match the script the purpose defines, under +strict validation: `bip44` p2pkh, `bip49` p2sh, `bip84` p2wpkh or bech32, +`bip86` p2tr or bech32m; `bip32` takes any. `ap` is conventionally `1` +(P2PKH), `3` (P2SH), `bc1q` (bech32), `bc1p` (bech32m / Taproot). #### Ethereum and EVM clones (`evm`) Single canonical format (hex). Chain ID typically a numeric chain ID such as @@ -187,6 +189,8 @@ C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, #### Cosmos (`cosmos`) Cosmos chains traditionally use BIP-44 with coin type 118; some deployments register their own SLIP-44 entries. CIP-11 is `m/44'/118'/account'/charge_extra/address`. +`bip44` with coin `118'` is the same path as `cip11`, so strict validation +refuses it and keeps the `cip11` spelling; `bip44` with another coin stays. #### Solana (`solana`) SLIP-10 ed25519 only. Common path forms: `m/44'/501'`, `m/44'/501'/account'`, @@ -194,7 +198,9 @@ SLIP-10 ed25519 only. Common path forms: `m/44'/501'`, `m/44'/501'/account'`, #### XRP Ledger (`xrpl`) secp256k1 is the historical default; ed25519 is supported by newer wallets. -Addresses use base58 with XRPL's custom alphabet (start with `r`). +XRPL ed25519 keys come from a family seed, not an HD path, so their URN is +root (`aa:ed25519`, no `dt`); `bip44` with ed25519 fails strict validation +(see §6.2). Addresses use base58 with XRPL's custom alphabet (start with `r`). #### Stellar (`stellar`) SEP-0005 mandates SLIP-10 ed25519 with `m/44'/148'/account'` (3 levels, all @@ -205,11 +211,14 @@ SEP-0023. Common version bytes: `G` (account), `S` (seed), `M` (muxed), #### NEAR (`near`) ed25519-implicit accounts use raw 64-char hex of pubkey. ETH-implicit accounts use `0x` + 40 hex from secp256k1 + keccak256. Named accounts (`alice.near`) -are not HD-derived and not represented by MHDA. +are not HD-derived and not represented by MHDA. ed25519 keys derive through +`slip10`, secp256k1 keys through `bip44`; strict validation holds each +derivation type to its curve. #### Aptos (`aptos`) ed25519 path `m/44'/637'/account'/change'/index'` (all 5 hardened, enforced by aptos-ts-sdk). secp256k1 path `m/44'/637'/account'/change/index` (BIP-44). +Strict validation holds `slip10` to ed25519 and `bip44` to secp256k1. #### Sui (`sui`) The signature scheme is encoded in the `purpose` field of the path: @@ -221,7 +230,9 @@ secp256r1: m/74'/784'/account'/change/index (BIP-32, purpose=74') ``` Address = Blake2b-256(flag || pubkey), 0x + 64 hex chars. Flag bytes: -0x00 ed25519, 0x01 secp256k1, 0x02 secp256r1. +0x00 ed25519, 0x01 secp256k1, 0x02 secp256r1. Strict validation holds each +derivation type to its scheme: `slip10` ed25519, `bip54` secp256k1, `bip74` +secp256r1. #### Cardano (`cardano`) BIP32-Ed25519 (extended keys with soft-derivation, distinct from SLIP-10 @@ -375,6 +386,19 @@ as if rewritten in long form. ROOT is always accepted regardless of network. +Strict validation also refuses combinations no wallet can derive or that +name one key twice: + +- ed25519 with any unhardened level, except under `cip1852`: SLIP-10 + derives ed25519 keys through hardened levels only (CIP-1852 is + BIP32-Ed25519, which has soft derivation). +- A derivation type with a curve other than its own where the network binds + them (Sui, Aptos, NEAR; see §2.1). +- A Bitcoin format that does not match the purpose of the path (§2.1). +- A Cosmos `bip44` path with coin `118'`, which is the `cip11` path. + +These fail with `ErrIncompatible`; lenient parsing still accepts the paths. + An address built in code can have a derivation type without a path: `SetDerivationType` with a new type drops the old path, which belongs to the old scheme. Such an address serialises with `dt` but no `dp`, so its URN does diff --git a/src/address.cpp b/src/address.cpp index 6264aa5..2e1cdbe 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -283,13 +283,45 @@ void address::validate() const { } // ROOT (no derivation path) is always permitted. - if (path_ && !path_->type().empty() && path_->type() != derivation_type::root) { - if (!detail::network_allows_derivation(nt, path_->type())) { - throw parse_error(error_code::incompatible, - std::string{"derivation \""} + path_->type().str() + - "\" not allowed for network \"" + nt.str() + "\""); + if (!path_ || path_->type().empty() || path_->type() == derivation_type::root) return; + const derivation_type& dt = path_->type(); + if (!detail::network_allows_derivation(nt, dt)) { + throw parse_error(error_code::incompatible, + std::string{"derivation \""} + dt.str() + + "\" not allowed for network \"" + nt.str() + "\""); + } + const algorithm want = detail::derivation_algorithm(nt, dt); + if (!want.empty() && want != algo) { + throw parse_error(error_code::incompatible, + std::string{"derivation \""} + dt.str() + "\" derives \"" + want.str() + + "\" keys on network \"" + nt.str() + "\", not \"" + algo.str() + "\""); + } + if (!fmt.empty() && !detail::derivation_allows_format(nt, dt, fmt)) { + throw parse_error(error_code::incompatible, + std::string{"format \""} + fmt.str() + + "\" does not match the purpose of derivation \"" + dt.str() + + "\" on network \"" + nt.str() + "\""); + } + // SLIP-10 derives ed25519 keys through hardened levels only; CIP-1852 + // (BIP32-Ed25519) is the one ed25519 scheme with soft derivation. + if (algo == algorithm::ed25519 && dt != derivation_type::cip1852) { + const auto& lvls = path_->levels(); + for (std::size_t i = 0; i < lvls.size(); ++i) { + if (!lvls[i].is_hardened) { + throw parse_error(error_code::incompatible, + "ed25519 derives hardened levels only, level " + + std::to_string(i) + " of \"" + path_->str() + + "\" is not hardened"); + } } } + // On Cosmos, bip44 with coin 118' is the cip11 path under another name; + // strict mode keeps the one spelling. + if (nt == network_type::cosmos && dt == derivation_type::bip44 && + path_->coin() == coins::atom) { + throw parse_error(error_code::incompatible, + "\"bip44\" with coin 118' is the cip11 path, use dt:cip11"); + } } std::string address::hash() const { diff --git a/src/compatibility.cpp b/src/compatibility.cpp index 8793307..9894364 100644 --- a/src/compatibility.cpp +++ b/src/compatibility.cpp @@ -14,6 +14,12 @@ struct network_compat { std::unordered_set derivations; algorithm default_algorithm; format default_format; + // derivation_algorithm binds a derivation type to the one algorithm it + // derives on this network; unbound types take any listed algorithm. + std::unordered_map derivation_algorithm = {}; + // derivation_formats binds a derivation type to the formats its purpose + // defines, checked when a format is resolved; unbound types take any. + std::unordered_map> derivation_formats = {}; }; const std::unordered_map& matrix() { @@ -31,6 +37,15 @@ const std::unordered_map& matrix() { derivation_type::bip86}, algorithm::secp256k1, format{}, // no default — multiple legitimate scripts + {}, + // The purpose defines the script, so an explicit format must + // match it. + { + {derivation_type::bip44, {format::p2pkh}}, + {derivation_type::bip49, {format::p2sh}}, + {derivation_type::bip84, {format::p2wpkh, format::bech32}}, + {derivation_type::bip86, {format::p2tr, format::bech32m}}, + }, }); // EthereumVM m.emplace(network_type::ethereum_vm, network_compat{ @@ -95,6 +110,8 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip44}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip44, algorithm::secp256k1}}, }); // Aptos m.emplace(network_type::aptos, network_compat{ @@ -103,6 +120,8 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip44}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip44, algorithm::secp256k1}}, }); // Sui m.emplace(network_type::sui, network_compat{ @@ -111,6 +130,9 @@ const std::unordered_map& matrix() { {derivation_type::slip10, derivation_type::bip54, derivation_type::bip74}, algorithm::ed25519, format::hex, + {{derivation_type::slip10, algorithm::ed25519}, + {derivation_type::bip54, algorithm::secp256k1}, + {derivation_type::bip74, algorithm::secp256r1}}, }); // Cardano m.emplace(network_type::cardano, network_compat{ @@ -171,6 +193,22 @@ bool network_allows_format(const network_type& nt, const format& fmt) { return it->second.formats.find(fmt) != it->second.formats.end(); } +algorithm derivation_algorithm(const network_type& nt, const derivation_type& dt) { + auto it = matrix().find(nt); + if (it == matrix().end()) return algorithm{}; + auto bound = it->second.derivation_algorithm.find(dt); + if (bound == it->second.derivation_algorithm.end()) return algorithm{}; + return bound->second; +} + +bool derivation_allows_format(const network_type& nt, const derivation_type& dt, const format& fmt) { + auto it = matrix().find(nt); + if (it == matrix().end()) return true; + auto bound = it->second.derivation_formats.find(dt); + if (bound == it->second.derivation_formats.end()) return true; + return bound->second.find(fmt) != bound->second.end(); +} + bool network_allows_derivation(const network_type& nt, const derivation_type& dt) { auto it = matrix().find(nt); if (it == matrix().end()) return false; diff --git a/src/compatibility.hpp b/src/compatibility.hpp index 51589e9..1cdfe4f 100644 --- a/src/compatibility.hpp +++ b/src/compatibility.hpp @@ -29,6 +29,16 @@ bool network_allows_format(const network_type& nt, const format& fmt); // derivation scheme. ROOT is considered valid for every registered network. bool network_allows_derivation(const network_type& nt, const derivation_type& dt); +// derivation_algorithm returns the one algorithm dt derives on nt (Sui: +// slip10 ed25519, bip54 secp256k1, bip74 secp256r1; Aptos and NEAR: slip10 +// ed25519, bip44 secp256k1), or an empty algorithm when nt does not bind dt. +algorithm derivation_algorithm(const network_type& nt, const derivation_type& dt); + +// derivation_allows_format reports whether fmt is a script the purpose of dt +// defines on nt (Bitcoin: bip44 p2pkh, bip49 p2sh, bip84 p2wpkh or bech32, +// bip86 p2tr or bech32m); true when nt does not bind dt to formats. +bool derivation_allows_format(const network_type& nt, const derivation_type& dt, const format& fmt); + // network_is_registered reports whether the network type is in the // compatibility matrix at all. bool network_is_registered(const network_type& nt); diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt index ce5a55f..278aff0 100644 --- a/tests/data/urn_conformance.txt +++ b/tests/data/urn_conformance.txt @@ -1,17 +1,19 @@ # URN conformance table, shared verbatim by go-mhda # (testdata/urn_conformance.txt) and the C++ port (tests/data/urn_conformance.txt). -# Both test suites run every row through the lenient parser; an input one -# implementation refuses, the other refuses too, with the same error. Edit -# both copies together. +# Both test suites run every row; an input one implementation refuses, the +# other refuses too, with the same error. Edit both copies together. # # Row forms: # accept urn # accept nss +# accept strict # refuse urn # refuse nss -# is the C++ error_code name; go-mhda maps it to its sentinel -# (invalid_nss is ErrInvalidNSS, invalid_derivation_path is -# ErrInvalidDerivationPath, ...). +# refuse strict +# urn rows use the lenient parser, strict rows ParseURNStrict / +# parse_urn_strict. is the C++ error_code name; go-mhda maps it to +# its sentinel (invalid_nss is ErrInvalidNSS, incompatible is +# ErrIncompatible, ...). # A derivation path needs a derivation type other than root. Without dt the # address is root, and a dp there would be dropped: the URN would name the @@ -63,3 +65,60 @@ accept urn urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0#frag urn:mhda:nt:ev accept urn urn:mhda:nt:ton:ci:mainnet:dt:root urn:mhda:nt:ton:ci:mainnet accept urn urn:mhda:nt:algorand:ci:mainnet:ct:283:dt:root:af:base32 urn:mhda:nt:algorand:ci:mainnet:ct:283:af:base32 accept urn urn:mhda:nt:evm:ci:1:dt:ROOT urn:mhda:nt:evm:ci:1 + +# Strict mode and the curves. SLIP-10 derives ed25519 keys through hardened +# levels only, so every level of an ed25519 path must be hardened; cip1852 +# (BIP32-Ed25519) is the one ed25519 scheme with soft levels. The lenient +# parser still accepts these paths. +refuse strict incompatible urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 +refuse strict incompatible urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'/7 +refuse strict incompatible urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0 +refuse strict incompatible urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0'/0'/0 +refuse strict incompatible urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0:aa:ed25519 +refuse strict incompatible urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0:aa:ed25519 +accept urn urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0/0 +accept strict urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0' urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0' +accept strict urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0' urn:mhda:nt:stellar:ci:pubnet:dt:slip10:dp:m/44'/148'/0' +accept strict urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0' urn:mhda:nt:ton:ci:mainnet:dt:slip10:dp:m/44'/607'/0' +accept strict urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0 urn:mhda:nt:cardano:ci:mainnet:dt:cip1852:dp:m/1852'/1815'/0'/0/0 +accept strict urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0 urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0 + +# Sui encodes the signature scheme in the purpose (bip54 secp256k1, bip74 +# secp256r1, slip10 ed25519); Aptos and NEAR derive ed25519 through slip10 +# and secp256k1 through bip44. A derivation type takes only its own curve. +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256r1 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256k1 +refuse strict incompatible urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0':aa:secp256k1 +refuse strict incompatible urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0 +refuse strict incompatible urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0':aa:secp256k1 +refuse strict incompatible urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/0 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1 urn:mhda:nt:sui:ci:mainnet:dt:bip54:dp:m/54'/784'/0'/0/0:aa:secp256k1 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1 urn:mhda:nt:sui:ci:mainnet:dt:bip74:dp:m/74'/784'/0'/0/0:aa:secp256r1 +accept strict urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0' urn:mhda:nt:sui:ci:mainnet:dt:slip10:dp:m/44'/784'/0'/0'/0' +accept strict urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1 urn:mhda:nt:aptos:ci:mainnet:dt:bip44:dp:m/44'/637'/0'/0/0:aa:secp256k1 +accept strict urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0' urn:mhda:nt:aptos:ci:mainnet:dt:slip10:dp:m/44'/637'/0'/0'/0' +accept strict urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/1:aa:secp256k1 urn:mhda:nt:near:ci:mainnet:dt:bip44:dp:m/44'/397'/0'/0/1:aa:secp256k1 +accept strict urn:mhda:nt:near:ci:mainnet:dt:slip10:dp:m/44'/397'/0' urn:mhda:nt:near:ci:mainnet:dt:slip10:dp:m/44'/397'/0' + +# Bitcoin: the purpose defines the script, so an explicit af must match it +# (bip44 p2pkh, bip49 p2sh, bip84 p2wpkh or bech32, bip86 p2tr or bech32m; +# bip32 takes any). Without af the URN stays valid. +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2pkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2wpkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2wpkh +refuse strict incompatible urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0 urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32 urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32 +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh +accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip32:dp:m/0'/0/0:af:p2wpkh urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip32:dp:m/0'/0/0:af:p2wpkh + +# Cosmos: bip44 with coin 118' is the cip11 path m/44'/118'/... (one key, +# two spellings); strict mode keeps cip11. bip44 with another coin stays. +refuse strict incompatible urn:mhda:nt:cosmos:ci:cosmoshub:dt:bip44:dp:m/44'/118'/0'/0/0 +accept strict urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 +accept strict urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 diff --git a/tests/test_urn_conformance.cpp b/tests/test_urn_conformance.cpp index fc707eb..25b2cd6 100644 --- a/tests/test_urn_conformance.cpp +++ b/tests/test_urn_conformance.cpp @@ -28,6 +28,7 @@ const std::map& conformance_errors() { {"invalid_algorithm", error_code::invalid_algorithm}, {"invalid_format", error_code::invalid_format}, {"invalid_value", error_code::invalid_value}, + {"incompatible", error_code::incompatible}, }; return m; } @@ -50,7 +51,7 @@ TEST_CASE("URN conformance table shared with go-mhda") { std::string verdict, kind, a, b, extra; fields >> verdict >> kind >> a >> b >> extra; const std::string where = "urn_conformance.txt:" + std::to_string(line) + ": " + text; - if (!extra.empty() || b.empty() || (kind != "urn" && kind != "nss") || + if (!extra.empty() || b.empty() || (kind != "urn" && kind != "nss" && kind != "strict") || (verdict != "accept" && verdict != "refuse")) { mhda_failures.push_back({__FILE__, __LINE__, "malformed row, " + where}); return; @@ -69,7 +70,9 @@ TEST_CASE("URN conformance table shared with go-mhda") { } try { - const address addr = kind == "urn" ? parse_urn(input) : parse_nss(input); + const address addr = kind == "urn" ? parse_urn(input) + : kind == "strict" ? parse_urn_strict(input) + : parse_nss(input); if (!accept) { mhda_failures.push_back({__FILE__, __LINE__, "accepted, " + where}); } else if (addr.str() != b) { diff --git a/tests/test_validate.cpp b/tests/test_validate.cpp index 68c09aa..7e917f1 100644 --- a/tests/test_validate.cpp +++ b/tests/test_validate.cpp @@ -71,9 +71,10 @@ TEST_CASE("derivation compatibility leg of strict mode") { TEST_CASE("Bitcoin all script formats") { const std::vector urns = { "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2pkh", - "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip44:dp:m/44'/0'/0'/0/0:af:p2sh", + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip49:dp:m/49'/0'/0'/0/0:af:p2sh", "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wpkh", - "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:p2wsh", + // P2WSH has no single-key purpose (multisig is BIP-48): plain bip32. + "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip32:dp:m/0'/0/0:af:p2wsh", "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip84:dp:m/84'/0'/0'/0/0:af:bech32", "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:p2tr", "urn:mhda:nt:bitcoin:ci:bitcoin:ct:0:dt:bip86:dp:m/86'/0'/0'/0/0:af:bech32m", @@ -124,7 +125,7 @@ TEST_CASE("README examples round-trip strictly") { "urn:mhda:nt:solana:ci:mainnet:dt:slip10:dp:m/44'/501'/0'/0'", // XRP Ledger "urn:mhda:nt:xrpl:ci:mainnet:dt:bip44:dp:m/44'/144'/0'/0/0", - "urn:mhda:nt:xrpl:ci:mainnet:ct:144:dt:bip44:dp:m/44'/144'/0'/0/0:aa:ed25519", + "urn:mhda:nt:xrpl:ci:mainnet:ct:144:aa:ed25519", // Stellar "urn:mhda:nt:stellar:ci:mainnet:dt:slip10:dp:m/44'/148'/0'", // NEAR From f5aaa1000a04b8050c0358f98b8ee54c691c9d1c Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:01:21 +0300 Subject: [PATCH 16/21] Build as a well-behaved CMake dependency Embedded with add_subdirectory or FetchContent, mhda forced CMAKE_BUILD_TYPE=Release into the cache of a parent that had none, so the parent's own code was built with -O3 -DNDEBUG; it also built its tests and examples into the parent and added its install rules there. These now apply only to a top-level build, and MHDA_BUILD_TESTS, MHDA_BUILD_EXAMPLES and the new MHDA_INSTALL default to it. BUILD_SHARED_LIBS=ON gave a libmhda.so without any mhda symbol (hidden visibility, no export macros); a shared build now exports its symbols (WINDOWS_EXPORT_ALL_SYMBOLS for a DLL) and passes the suite, a static one keeps them hidden. mhda::mhda requires cxx_std_17 publicly, so a C++14 consumer is raised to C++17 instead of failing on std::optional, and the install include path follows CMAKE_INSTALL_INCLUDEDIR. tests/cmake holds a find_package consumer (asking for C++14) and an add_subdirectory consumer that fails if mhda touches the parent's build type or targets; CI builds both and a shared library. --- .github/workflows/ci.yml | 34 ++++++++ CHANGELOG.md | 19 ++++- CMakeLists.txt | 102 +++++++++++++++--------- README.md | 16 +++- tests/cmake/consumer.cpp | 8 ++ tests/cmake/find_package/CMakeLists.txt | 9 +++ tests/cmake/subdirectory/CMakeLists.txt | 19 +++++ 7 files changed, 164 insertions(+), 43 deletions(-) create mode 100644 tests/cmake/consumer.cpp create mode 100644 tests/cmake/find_package/CMakeLists.txt create mode 100644 tests/cmake/subdirectory/CMakeLists.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cb2f07..efd09c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,40 @@ jobs: UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 run: ctest --test-dir build --output-on-failure + shared: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure + + # The two ways a project consumes mhda: an installed package found with + # find_package (from a project that asks for C++14), and add_subdirectory / + # FetchContent, which must leave the parent's build type and targets alone. + packaging: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build and install + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMHDA_BUILD_TESTS=OFF -DMHDA_BUILD_EXAMPLES=OFF + cmake --build build --parallel + cmake --install build --prefix "$PWD/prefix" + - name: find_package consumer + run: | + cmake -S tests/cmake/find_package -B build-find-package -DCMAKE_PREFIX_PATH="$PWD/prefix" + cmake --build build-find-package + ./build-find-package/consumer + - name: add_subdirectory consumer + run: | + cmake -S tests/cmake/subdirectory -B build-subdirectory -DMHDA_SOURCE_DIR="$PWD" + cmake --build build-subdirectory + ./build-subdirectory/consumer + # The warning set the README promises, as errors. Pinned to the GCC the # README lists as verified (Ubuntu 22.04, GCC 11): a newer compiler may add # warnings that need a deliberate look rather than a red build. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1235f..33cab1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,21 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. ### Fixed +- **Embedding mhda leaves the parent build alone.** Added with + `add_subdirectory` or FetchContent, mhda forced `CMAKE_BUILD_TYPE=Release` + into the cache of a parent that had none (so the parent's own code was + built with `-O3 -DNDEBUG` and its asserts off), built its tests and + examples into the parent and added its install rules to the parent's + install. These now apply only to a top-level build; `MHDA_BUILD_TESTS`, + `MHDA_BUILD_EXAMPLES` and the new `MHDA_INSTALL` default to it. +- **`BUILD_SHARED_LIBS=ON` produces a usable library.** The hidden-symbol + preset, without export macros, left `libmhda.so` with no mhda symbols + and every consumer failed to link. A shared mhda now exports its symbols + (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. +- **The installed package is complete.** `mhda::mhda` now requires + `cxx_std_17` publicly, so a consumer asking for C++14 is raised to C++17 + instead of failing on `std::optional`, and the install include path + follows `CMAKE_INSTALL_INCLUDEDIR`. - **No stale or mixed derivation state.** `set_type` and `address::set_derivation_type` changed the type and kept the old path, so a bip44 address switched to zip32 serialised as @@ -165,7 +180,9 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. and a global destructor; hash digests are pinned at the SHA block and padding boundaries (55 to 1000 bytes). - CI adds an ASan/UBSan job and a `-Werror` job with the README's warning - set, and runs with read-only permissions. 152 test cases total. + set, a shared-library job, and a packaging job that builds the + `find_package` and `add_subdirectory` consumers in `tests/cmake/`; it + runs with read-only permissions. 152 test cases total. ## [1.1.0] — 2026-07-04 diff --git a/CMakeLists.txt b/CMakeLists.txt index 6dea226..12ea387 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,17 +6,30 @@ project(mhda LANGUAGES CXX ) -option(MHDA_BUILD_TESTS "Build the mhda test suite" ON) -option(MHDA_BUILD_EXAMPLES "Build the mhda example programs" ON) +# Built on its own, or embedded in another project (add_subdirectory, +# FetchContent)? Only a top-level build picks a build type and, by default, +# builds the tests and examples and generates install rules; an embedding +# project keeps its own settings and can opt in. +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(MHDA_IS_TOP_LEVEL ON) +else() + set(MHDA_IS_TOP_LEVEL OFF) +endif() + +option(MHDA_BUILD_TESTS "Build the mhda test suite" ${MHDA_IS_TOP_LEVEL}) +option(MHDA_BUILD_EXAMPLES "Build the mhda example programs" ${MHDA_IS_TOP_LEVEL}) +option(MHDA_INSTALL "Generate the mhda install rules" ${MHDA_IS_TOP_LEVEL}) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) +if(MHDA_IS_TOP_LEVEL AND NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() +include(GNUInstallDirs) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") add_compile_options(-Wall -Wextra -Wpedantic -Wshadow) endif() @@ -41,16 +54,28 @@ add_library(mhda::mhda ALIAS mhda) target_include_directories(mhda PUBLIC $ - $ + $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) -set_target_properties(mhda PROPERTIES - POSITION_INDEPENDENT_CODE ON - CXX_VISIBILITY_PRESET hidden - VISIBILITY_INLINES_HIDDEN ON -) +# The public headers need C++17 (std::optional, inline variables): a +# consumer that asks for an older standard is raised to C++17. +target_compile_features(mhda PUBLIC cxx_std_17) + +set_target_properties(mhda PROPERTIES POSITION_INDEPENDENT_CODE ON) +get_target_property(MHDA_LIBRARY_TYPE mhda TYPE) +if(MHDA_LIBRARY_TYPE STREQUAL "SHARED_LIBRARY") + # There are no export macros: a shared mhda exports every symbol (and + # needs WINDOWS_EXPORT_ALL_SYMBOLS for a DLL). + set_target_properties(mhda PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) +else() + # A static mhda linked into a consumer's shared object stays private to it. + set_target_properties(mhda PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + ) +endif() if(MHDA_BUILD_TESTS) enable_testing() @@ -61,34 +86,35 @@ if(MHDA_BUILD_EXAMPLES) add_subdirectory(examples) endif() -include(GNUInstallDirs) -include(CMakePackageConfigHelpers) +if(MHDA_INSTALL) + include(CMakePackageConfigHelpers) -install(TARGETS mhda - EXPORT mhdaTargets - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} -) -install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) -install(EXPORT mhdaTargets - FILE mhdaTargets.cmake - NAMESPACE mhda:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) + install(TARGETS mhda + EXPORT mhdaTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + ) + install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + install(EXPORT mhdaTargets + FILE mhdaTargets.cmake + NAMESPACE mhda:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) -configure_package_config_file( - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mhdaConfig.cmake.in" - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" - INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) -write_basic_package_version_file( - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" - VERSION ${PROJECT_VERSION} - COMPATIBILITY SameMajorVersion -) -install(FILES - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" - "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda -) + configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/mhdaConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion + ) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/mhdaConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mhda + ) +endif() diff --git a/README.md b/README.md index 2b7f343..03e30a8 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,11 @@ ctest --test-dir build --output-on-failure ``` The library target is `mhda::mhda`; public headers live under `include/mhda/`. -Both options below are ON by default and can be disabled with -`-DMHDA_BUILD_TESTS=OFF` / `-DMHDA_BUILD_EXAMPLES=OFF`. +Built on its own, the project builds its tests and examples and generates +install rules; embedded with `add_subdirectory` / FetchContent it builds only +the library and leaves the parent's build type alone. `-DMHDA_BUILD_TESTS`, +`-DMHDA_BUILD_EXAMPLES` and `-DMHDA_INSTALL` override either default, and +`-DBUILD_SHARED_LIBS=ON` builds a shared library. ### Installing @@ -66,8 +69,9 @@ Both options below are ON by default and can be disabled with cmake --install build --prefix /usr/local ``` -This installs `libmhda.a`, the `include/mhda/` headers and a -`mhda::mhda` CMake export so downstream projects can: +This installs the library, the `mhda/` headers under +`CMAKE_INSTALL_INCLUDEDIR` and a `mhda::mhda` CMake export, which carries the +C++17 requirement, so downstream projects can: ```cmake find_package(mhda REQUIRED) @@ -86,6 +90,10 @@ FetchContent_MakeAvailable(mhda) target_link_libraries(my_app PRIVATE mhda::mhda) ``` +Embedded this way, mhda adds only its library target: tests, examples and +install rules stay off unless enabled, and it does not choose a build type +for the parent. + ## Quick start ```cpp diff --git a/tests/cmake/consumer.cpp b/tests/cmake/consumer.cpp new file mode 100644 index 0000000..935808c --- /dev/null +++ b/tests/cmake/consumer.cpp @@ -0,0 +1,8 @@ +// Consumer used by the packaging checks in tests/cmake: it only needs to +// compile against the public headers, link mhda::mhda and run. +#include + +int main() { + const auto a = mhda::parse_urn_strict("urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0"); + return a.str() == "urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0" ? 0 : 1; +} diff --git a/tests/cmake/find_package/CMakeLists.txt b/tests/cmake/find_package/CMakeLists.txt new file mode 100644 index 0000000..dbdc496 --- /dev/null +++ b/tests/cmake/find_package/CMakeLists.txt @@ -0,0 +1,9 @@ +# Consumes an installed mhda through find_package. The project asks for +# C++14: the mhda::mhda target must raise its consumers to C++17 itself. +cmake_minimum_required(VERSION 3.14) +project(mhda_find_package_consumer LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 14) +find_package(mhda REQUIRED) +add_executable(consumer ../consumer.cpp) +target_link_libraries(consumer PRIVATE mhda::mhda) diff --git a/tests/cmake/subdirectory/CMakeLists.txt b/tests/cmake/subdirectory/CMakeLists.txt new file mode 100644 index 0000000..c7638df --- /dev/null +++ b/tests/cmake/subdirectory/CMakeLists.txt @@ -0,0 +1,19 @@ +# Embeds mhda with add_subdirectory, the way FetchContent does. The parent +# sets no build type, and mhda must not choose one for it, nor add its +# tests, examples or install rules. Configure with -DMHDA_SOURCE_DIR=. +cmake_minimum_required(VERSION 3.14) +project(mhda_subdirectory_consumer LANGUAGES CXX) + +add_subdirectory(${MHDA_SOURCE_DIR} mhda) + +if(CMAKE_BUILD_TYPE) + message(FATAL_ERROR "mhda set the parent's build type to '${CMAKE_BUILD_TYPE}'") +endif() +foreach(t mhda_tests mhda_basic mhda_by_key) + if(TARGET ${t}) + message(FATAL_ERROR "mhda added its target '${t}' to the parent build") + endif() +endforeach() + +add_executable(consumer ../consumer.cpp) +target_link_libraries(consumer PRIVATE mhda::mhda) From 135372e150dbf42d430688cda7cc93473ca1b473 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:01:58 +0300 Subject: [PATCH 17/21] Compile the headers under the near macro (minwindef.h) defines near as an empty macro, so constexpr coin_type near = 397 in coin_type.hpp became a syntax error in any translation unit that included first; the wallet builds for Windows. coin_type.hpp now sets the macro aside with push_macro / pop_macro around the coin list, and adds coins::near_protocol, which stays usable while the macro is in effect. A test translation unit defines near and far the way does. --- CHANGELOG.md | 6 ++++++ include/mhda/coin_type.hpp | 18 +++++++++++++++++- tests/CMakeLists.txt | 1 + tests/test_windows_macros.cpp | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/test_windows_macros.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 33cab1e..e8f9153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,12 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. preset, without export macros, left `libmhda.so` with no mhda symbols and every consumer failed to link. A shared mhda now exports its symbols (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. +- **The headers compile under ``.** Its `near` macro (empty, from + `minwindef.h`) turned `coins::near` into a syntax error in any + translation unit that included `` first. `coin_type.hpp` now + sets the macro aside for its list and restores it, and the new alias + `coins::near_protocol` (after `network_type::near_protocol`) stays usable + while the macro is in effect. - **The installed package is complete.** `mhda::mhda` now requires `cxx_std_17` publicly, so a consumer asking for C++14 is raised to C++17 instead of failing on `std::optional`, and the install include path diff --git a/include/mhda/coin_type.hpp b/include/mhda/coin_type.hpp index 5db5ae1..be74597 100644 --- a/include/mhda/coin_type.hpp +++ b/include/mhda/coin_type.hpp @@ -7,9 +7,19 @@ namespace mhda { // CoinType is a SLIP-44 coin type (32-bit unsigned integer). using coin_type = std::uint32_t; +// (minwindef.h) defines `near` as an empty macro, which would +// turn coins::near below into a syntax error. It is set aside for this list +// and restored afterwards; while it is in effect, use coins::near_protocol. +#if defined(near) +#pragma push_macro("near") +#undef near +#define MHDA_COIN_TYPE_RESTORE_NEAR +#endif + // SLIP-44 registered coin types, ascending by index // (https://github.com/satoshilabs/slips/blob/master/slip-0044.md). -// Matches go-mhda/coin_type.go. +// Matches go-mhda/coin_type.go. Several names (move, near) are common words: +// qualify them (coins::move) rather than `using namespace mhda::coins`. namespace coins { constexpr coin_type btc = 0; @@ -35,6 +45,7 @@ constexpr coin_type zil = 313; constexpr coin_type luna = 330; constexpr coin_type dot = 354; constexpr coin_type near = 397; +constexpr coin_type near_protocol = near; // usable under constexpr coin_type ksm = 434; constexpr coin_type kava = 459; constexpr coin_type fil = 461; @@ -75,4 +86,9 @@ constexpr coin_type mon = 268435779; } // namespace coins +#if defined(MHDA_COIN_TYPE_RESTORE_NEAR) +#pragma pop_macro("near") +#undef MHDA_COIN_TYPE_RESTORE_NEAR +#endif + } // namespace mhda diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 78cb027..c15433a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,7 @@ add_executable(mhda_tests test_static_init.cpp test_urn_conformance.cpp test_state.cpp + test_windows_macros.cpp ) target_link_libraries(mhda_tests PRIVATE mhda) target_include_directories(mhda_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/test_windows_macros.cpp b/tests/test_windows_macros.cpp new file mode 100644 index 0000000..9a5fce5 --- /dev/null +++ b/tests/test_windows_macros.cpp @@ -0,0 +1,18 @@ +// (minwindef.h) defines `near` and `far` as empty macros. The +// public headers must compile with them in effect, as they are in any Windows +// translation unit that includes first. +#define near +#define far + +#include "mhda/mhda.hpp" +#include "ostream_helpers.hpp" +#include "test_framework.hpp" + +TEST_CASE("headers compile under the near/far macros") { + // coins::near itself is unusable while the macro is defined; its alias + // near_protocol (after network_type::near_protocol) is not. + EXPECT_EQ(mhda::coins::near_protocol, mhda::coin_type{397}); +} + +#undef near +#undef far From 5d3ab96c4149f8f301484f7caa176840e6605bfd Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:03:30 +0300 Subject: [PATCH 18/21] Escape the input quoted in error messages parse_error::what() copied the caller's input verbatim into the message, so a newline or an ANSI escape in a client-supplied value (set_wallet_id("x\n[INFO] ok")) forged or coloured lines in any log that recorded the exception. Go escapes the same messages with %q. detail::quote escapes '"', '\', \n, \r, \t and every other control or non-ASCII byte (\xNN), and every message that quotes input uses it: setter values, derivation types and paths, chain keys, coin types and NSS keys. Messages made only of validated values are unchanged. --- CHANGELOG.md | 7 ++++++ src/address.cpp | 15 ++++++------- src/chain.cpp | 14 ++++++------ src/derivation_path.cpp | 17 +++++++-------- src/derivation_type.cpp | 2 +- src/detail.cpp | 30 ++++++++++++++++++++++++-- src/detail.hpp | 6 ++++++ src/nss_parser.cpp | 15 ++++++------- tests/test_hardening.cpp | 46 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 115 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f9153..579f846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,13 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. preset, without export macros, left `libmhda.so` with no mhda symbols and every consumer failed to link. A shared mhda now exports its symbols (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. +- **Error messages escape the input they quote.** `what()` copied the + caller's bytes verbatim, so a newline or an ANSI escape in a + client-supplied value (`set_wallet_id("x\n[INFO] ok")`) forged or + coloured lines in any log that recorded the exception. Quoted input is + now escaped like Go's `%q` (`\"`, `\\`, `\n`, `\r`, `\t`, `\xNN` for + other control and non-ASCII bytes), so `what()` is one line of printable + ASCII. - **The headers compile under ``.** Its `near` macro (empty, from `minwindef.h`) turned `coins::near` into a syntax error in any translation unit that included `` first. `coin_type.hpp` now diff --git a/src/address.cpp b/src/address.cpp index 2e1cdbe..223133f 100644 --- a/src/address.cpp +++ b/src/address.cpp @@ -61,7 +61,7 @@ void address::set_derivation_type(std::string_view dt) { next = derivation_type{lowered}; if (!next.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + lowered + "\""); + detail::quote(lowered)); } } if (!path_) path_.emplace(); @@ -93,8 +93,7 @@ void address::set_derivation_path(std::string_view dp) { // than the path it spells out. Mirrors go-mhda. if (!trimmed.empty()) { throw parse_error(error_code::invalid_derivation_path, - std::string{"root derivation must have empty path, got \""} + - std::string{trimmed} + "\""); + std::string{"root derivation must have empty path, got "} + detail::quote(trimmed)); } return; } @@ -111,7 +110,7 @@ void address::set_coin_type(std::string_view ct) { std::uint32_t v = 0; if (!detail::parse_uint32(trimmed, v)) { throw parse_error(error_code::invalid_coin_type, - std::string{"\""} + std::string{trimmed} + "\""); + detail::quote(trimmed)); } chain_.set_coin(v); } @@ -126,7 +125,7 @@ void address::set_address_algorithm(std::string_view aa) { algorithm next{lowered}; if (!next.is_valid()) { throw parse_error(error_code::invalid_algorithm, - std::string{"\""} + lowered + "\""); + detail::quote(lowered)); } algorithm_ = std::move(next); } @@ -141,7 +140,7 @@ void address::set_address_format(std::string_view af) { format next{lowered}; if (!next.is_valid()) { throw parse_error(error_code::invalid_format, - std::string{"\""} + lowered + "\""); + detail::quote(lowered)); } format_ = std::move(next); } @@ -261,13 +260,13 @@ void address::validate() const { check_path_set(); if (!detail::network_is_registered(nt)) { throw parse_error(error_code::incompatible, - std::string{"unknown network type \""} + nt.str() + "\""); + std::string{"unknown network type "} + detail::quote(nt.str())); } auto algo = resolved_algorithm(); if (algo.empty()) { throw parse_error(error_code::incompatible, - std::string{"no algorithm resolved for network \""} + nt.str() + "\""); + std::string{"no algorithm resolved for network "} + detail::quote(nt.str())); } if (!detail::network_allows_algorithm(nt, algo)) { throw parse_error(error_code::incompatible, diff --git a/src/chain.cpp b/src/chain.cpp index 89c604e..e8a6fbf 100644 --- a/src/chain.cpp +++ b/src/chain.cpp @@ -25,7 +25,7 @@ chain build_chain(const std::unordered_map& m) { network_type nt{nt_norm}; if (!nt.is_valid()) { throw parse_error(error_code::invalid_network_type, - std::string{"\""} + nt_norm + "\""); + detail::quote(nt_norm)); } auto ci_it = m.find(std::string{detail::comp_chain_id}); @@ -45,7 +45,7 @@ chain build_chain(const std::unordered_map& m) { coin_type ct = 0; if (!detail::parse_uint32(ct_trim, ct)) { throw parse_error(error_code::invalid_coin_type, - std::string{"\""} + std::string{ct_trim} + "\""); + detail::quote(ct_trim)); } out.set_coin(ct); } @@ -69,7 +69,7 @@ chain::chain(network_type nt, chain_id ci) { void chain::set_network(network_type nt) { if (!nt.is_valid()) { throw parse_error(error_code::invalid_network_type, - std::string{"\""} + nt.str() + "\""); + detail::quote(nt.str())); } network_ = std::move(nt); } @@ -99,8 +99,7 @@ chain chain::from_key(std::string_view key) { for (char c : trimmed) colons += c == ':'; if (!trimmed.empty() && colons % 2 == 0) { throw parse_error(error_code::invalid_chain_key, - std::string{"not a sequence of key:value pairs: \""} + - std::string{trimmed} + "\""); + std::string{"not a sequence of key:value pairs: "} + detail::quote(trimmed)); } auto components = detail::parse_nss_map(trimmed); if (components.find(std::string{detail::comp_coin_type}) != components.end()) { @@ -109,7 +108,7 @@ chain chain::from_key(std::string_view key) { for (const auto& kv : components) { if (kv.first != detail::comp_network_type && kv.first != detail::comp_chain_id) { throw parse_error(error_code::invalid_chain_key, - std::string{"unexpected component \""} + kv.first + "\""); + std::string{"unexpected component "} + detail::quote(kv.first)); } } if (components.find(std::string{detail::comp_network_type}) == components.end()) { @@ -121,8 +120,7 @@ chain chain::from_key(std::string_view key) { // are rejected, not silently normalised. if (out.str() != trimmed) { throw parse_error(error_code::invalid_chain_key, - std::string{"not in canonical form: \""} + - std::string{trimmed} + "\""); + std::string{"not in canonical form: "} + detail::quote(trimmed)); } return out; } diff --git a/src/derivation_path.cpp b/src/derivation_path.cpp index 26d3cfe..7d0b235 100644 --- a/src/derivation_path.cpp +++ b/src/derivation_path.cpp @@ -209,7 +209,7 @@ derivation_path::derivation_path(derivation_type dt, index_(index) { if (!type_.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + type_.str() + "\""); + detail::quote(type_.str())); } if (type_ == derivation_type::slip10) { throw std::invalid_argument( @@ -224,7 +224,7 @@ derivation_path derivation_path::from_levels(derivation_type dt, std::vector levels) { if (!dt.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + dt.str() + "\""); + detail::quote(dt.str())); } derivation_path dp; dp.type_ = std::move(dt); @@ -236,7 +236,7 @@ derivation_path derivation_path::from_levels(derivation_type dt, derivation_path derivation_path::parse(derivation_type dt, std::string_view path) { if (!dt.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + dt.str() + "\""); + detail::quote(dt.str())); } derivation_path dp; dp.type_ = std::move(dt); @@ -247,7 +247,7 @@ derivation_path derivation_path::parse(derivation_type dt, std::string_view path void derivation_path::set_type(const derivation_type& dt) { if (!dt.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + dt.str() + "\""); + detail::quote(dt.str())); } if (dt == type_) return; // The old levels and shortcuts belong to the old scheme. @@ -269,13 +269,12 @@ void derivation_path::parse_path(std::string_view path) { void derivation_path::parse_fresh(std::string_view path) { if (!type_.is_valid()) { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + type_.str() + "\""); + detail::quote(type_.str())); } if (type_ == derivation_type::root) { if (!path.empty()) { throw parse_error(error_code::invalid_derivation_path, - std::string{"root derivation must have empty path, got \""} - + std::string{path} + "\""); + std::string{"root derivation must have empty path, got "} + detail::quote(path)); } levels_.clear(); has_index_ = false; @@ -285,7 +284,7 @@ void derivation_path::parse_fresh(std::string_view path) { std::vector lvls; if (!parse_levels(path, lvls) || !validate_levels_for(type_, lvls)) { throw parse_error(error_code::invalid_derivation_path, - std::string{"\""} + std::string{path} + "\""); + detail::quote(path)); } if (type_ == derivation_type::bip32) { @@ -338,7 +337,7 @@ void derivation_path::parse_fresh(std::string_view path) { } } else { throw parse_error(error_code::invalid_derivation_type, - std::string{"\""} + type_.str() + "\""); + detail::quote(type_.str())); } rebuild_levels(); diff --git a/src/derivation_type.cpp b/src/derivation_type.cpp index 8e34090..f9ae82e 100644 --- a/src/derivation_type.cpp +++ b/src/derivation_type.cpp @@ -29,7 +29,7 @@ bool derivation_type::is_valid() const noexcept { derivation_type derivation_type_from_string(std::string_view s) { auto key = detail::normalize(s); if (dt_index().find(key) == dt_index().end()) { - throw parse_error(error_code::invalid_derivation_type, std::string{"\""} + std::string{s} + "\""); + throw parse_error(error_code::invalid_derivation_type, detail::quote(s)); } return derivation_type{std::move(key)}; } diff --git a/src/detail.cpp b/src/detail.cpp index 449d83a..1f2e60e 100644 --- a/src/detail.cpp +++ b/src/detail.cpp @@ -54,13 +54,39 @@ bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept { return true; } +std::string quote(std::string_view s) { + static constexpr char hex[] = "0123456789abcdef"; + std::string out; + out.reserve(s.size() + 2); + out += '"'; + for (char c : s) { + const auto b = static_cast(c); + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (b < 0x20 || b > 0x7e) { + out += "\\x"; + out += hex[b >> 4]; + out += hex[b & 0xf]; + } else { + out += c; + } + } + } + out += '"'; + return out; +} + void validate_free_form_value(std::string_view component, std::string_view v) { for (char c : v) { const auto b = static_cast(c); if (b < 0x21 || b > 0x7e || c == ':' || c == '?' || c == '#') { throw parse_error(error_code::invalid_value, - std::string{"\""} + std::string{v} + "\" for \"" + - std::string{component} + "\""); + quote(v) + " for " + quote(component)); } } } diff --git a/src/detail.hpp b/src/detail.hpp index 8fa9797..d67122a 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -92,6 +92,12 @@ inline std::vector split(std::string_view s, char sep) { // the coin-type ("ct") parsing paths. bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept; +// quote returns s in double quotes for an error message, escaping like Go's +// %q: '"' and '\\' get a backslash, \n \r \t their escapes, and every other +// control or non-ASCII byte \xNN. The message stays one line of printable +// ASCII, so input logged through what() cannot forge or colour log lines. +std::string quote(std::string_view s); + // validate_free_form_value guards a value written verbatim into the NSS // (ap/as/wt/wi and the chain id) against characters that would corrupt it: // the ':' component separator would inject foreign components on re-parse, diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index ad6508e..f1ee3fe 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -43,7 +43,7 @@ std::unordered_map parse_nss_map(std::string_view nss) auto parts = split(nss, ':'); if (parts.size() % 2 != 0) { throw parse_error(error_code::invalid_nss, - std::string{"missing value for \""} + std::string{parts.back()} + "\""); + std::string{"missing value for "} + quote(parts.back())); } for (std::size_t i = 0; i < parts.size(); i += 2) { const auto key = parts[i]; @@ -54,8 +54,7 @@ std::unordered_map parse_nss_map(std::string_view nss) const auto b = static_cast(c); if (b < 0x21 || b > 0x7e) { throw parse_error(error_code::invalid_nss, - std::string{"non-ASCII or control byte in component key \""} + - std::string{key} + "\""); + std::string{"non-ASCII or control byte in component key "} + quote(key)); } } // RFC 8141 NSS does not permit unescaped whitespace; trim ASCII @@ -65,7 +64,7 @@ std::unordered_map parse_nss_map(std::string_view nss) auto value = trim(parts[i + 1]); if (value.empty()) { throw parse_error(error_code::invalid_nss, - std::string{"empty value for \""} + std::string{key} + "\""); + std::string{"empty value for "} + quote(key)); } // Everything that survives the trim must be printable ASCII (SPEC // §1.5) — interior whitespace, control bytes and Unicode spaces are @@ -74,22 +73,20 @@ std::unordered_map parse_nss_map(std::string_view nss) const auto b = static_cast(c); if (b < 0x21 || b > 0x7e) { throw parse_error(error_code::invalid_nss, - std::string{"non-ASCII or control byte in value for \""} + - std::string{key} + "\""); + std::string{"non-ASCII or control byte in value for "} + quote(key)); } } if (!is_known_component(key)) { if (is_known_component(to_lower(key))) { throw parse_error(error_code::invalid_nss, - std::string{"component key \""} + std::string{key} + - "\" must be lowercase"); + "component key " + quote(key) + " must be lowercase"); } continue; // unknown component, skipped with its value } std::string key_str{key}; if (out.count(key_str)) { throw parse_error(error_code::invalid_nss, - std::string{"duplicate component \""} + key_str + "\""); + std::string{"duplicate component "} + quote(key_str)); } out.emplace(std::move(key_str), std::string{value}); } diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp index d9cb5f4..c785e7c 100644 --- a/tests/test_hardening.cpp +++ b/tests/test_hardening.cpp @@ -240,3 +240,49 @@ TEST_CASE("zip32 has no network") { EXPECT_THROW_CODE(parse_urn_strict(urn), error_code::incompatible); } } + +// Error messages quote the offending input. A caller that logs what() must +// not receive the input's raw bytes: a newline or an ANSI escape in a +// client-supplied value would forge or colour log lines. Like Go's %q, +// what() escapes quotes, backslashes, control and non-ASCII bytes. +TEST_CASE("error messages escape the input they quote") { + const std::vector> calls = { + [] { address a; a.set_wallet_id("x\n[INFO] ok"); }, + [] { address a; a.set_address_algorithm("r\x1b[31msa"); }, + [] { address a{chain{network_type::ethereum_vm, "1"}, std::nullopt}; + a.set_coin_type("6\x1b""0"); }, + [] { (void)derivation_type_from_string("bip\n44"); }, + [] { (void)derivation_path::parse(derivation_type::bip44, "m/44'\n/\"x\\"); }, + [] { (void)derivation_path::parse(derivation_type{"bip\x7f"}, "m/0"); }, + [] { (void)chain::from_key("nt:evm:ci:1:\x1b"); }, + [] { (void)chain{network_type{"ev\nm"}, "1"}; }, + [] { (void)parse_urn("urn:mhda:nt:evm:ci:1:dt:bip\xc2\xa0" "44:dp:m/0"); }, + }; + for (std::size_t i = 0; i < calls.size(); ++i) { + std::string what; + try { + calls[i](); + } catch (const parse_error& e) { + what = e.what(); + } + if (what.empty()) { + mhda_failures.push_back({__FILE__, __LINE__, + "call " + std::to_string(i) + " did not throw parse_error"}); + continue; + } + for (char c : what) { + const auto b = static_cast(c); + if (b < 0x20 || b > 0x7e) { + mhda_failures.push_back({__FILE__, __LINE__, + "call " + std::to_string(i) + ": raw byte in what(): " + what}); + break; + } + } + } + try { + address a; + a.set_wallet_id("x\n\"y\\"); + } catch (const parse_error& e) { + EXPECT_TRUE(std::string{e.what()}.find(R"("x\n\"y\\")") != std::string::npos); + } +} From d8d5ae6b45d54ac69819738ca4a6fd343e34b52a Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:04:34 +0300 Subject: [PATCH 19/21] Refuse whitespace around keys and values inside the NSS parse_nss_map trimmed every value, so urn:mhda:nt:evm:ci: 1 :dt: bip44 :... parsed like the same URN without the spaces, although SPEC section 6.1 says values contain no whitespace and only the whole URN is trimmed. parse_nss_map no longer trims; whitespace in a key or value fails the charset check with invalid_nss. parse_urn trims the URN and the whitespace left before a stripped r/q/f component (ci:0 #frag still parses); parse_nss, chain::from_nss and chain::from_key trim the NSS they are given as a whole. Mirrors go-mhda. --- CHANGELOG.md | 7 +++++++ SPEC.md | 18 ++++++++++++------ include/mhda/chain.hpp | 3 ++- include/mhda/parser.hpp | 3 ++- src/chain.cpp | 2 +- src/detail.hpp | 7 +++++++ src/nss_parser.cpp | 8 +++----- src/nss_parser.hpp | 4 ++-- src/parser.cpp | 22 +++++++++++++++------- tests/test_hardening.cpp | 25 +++++++++++++++++++++++++ 10 files changed, 76 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579f846..3e97344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,13 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. `118'`. A Bitcoin URN without `af` stays valid; SPEC.md no longer claims strict mode requires it. Lenient parsing is unchanged. Mirrors go-mhda. +- **No whitespace inside the NSS.** Each value was trimmed, so + `urn:mhda:nt:evm:ci: 1 :dt: bip44 :...` parsed like the URN without the + spaces, against SPEC §6.1. ASCII whitespace is now trimmed only around the + whole URN, around an NSS or chain key parsed on its own, and before a + stripped r/q/f component (`ci:0 #frag` still parses); around a key or + value it throws `parse_error(invalid_nss)`. Mirrors go-mhda. + ### Fixed - **Embedding mhda leaves the parent build alone.** Added with diff --git a/SPEC.md b/SPEC.md index a275aa0..e7c0ee3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -109,10 +109,14 @@ truncate the URN emitted from it on the next parse. NSS values must consist of ASCII characters allowed by RFC 8141 NSS production (`pchar / "/"` per RFC 3986). The reference implementation enforces printable -ASCII (0x21–0x7E) for every value: control bytes, whitespace of any kind and -non-ASCII bytes are rejected. Only ASCII whitespace is trimmed around the URN -and around values — a Unicode space is malformed input, never decoration to -strip. Percent-encoding is not implemented; if a value needs to contain `:` +ASCII (0x21–0x7E) for every key and value: control bytes, whitespace of any +kind and non-ASCII bytes are rejected. ASCII whitespace is trimmed only around +the whole URN (or around an NSS or chain key parsed on its own), never around +a key or value inside it; a Unicode space is malformed input, never decoration +to strip. The setters and the `...FromString` helpers follow the same rule: +they trim ASCII whitespace and fold ASCII case only, so a Unicode letter that +lowercases to an ASCII one (the Kelvin sign `K`) is not another spelling. +Percent-encoding is not implemented; if a value needs to contain `:` (currently no in-tree value does), percent-encoding support must be added. ### 1.6 Wallet domain @@ -363,8 +367,10 @@ Performs structural validation only: - Network type is one of the registered values. - Coin type, if present, is a valid 32-bit unsigned integer, spelled as plain decimal or `0x`-prefixed hex only (no other integer-literal forms). -- Values contain no whitespace (interior whitespace is rejected; surrounding - whitespace around the whole URN is trimmed). +- Values contain no whitespace. ASCII whitespace around the whole URN (or + around an NSS or chain key parsed on its own, or before a stripped r/q/f + component) is trimmed; around a key or value inside the NSS it is + rejected. - Chain ID is non-empty. - Derivation type, if present, is one of the registered constants. - Derivation path, if present, matches the regex of its derivation type, diff --git a/include/mhda/chain.hpp b/include/mhda/chain.hpp index 0a1142b..ca372c4 100644 --- a/include/mhda/chain.hpp +++ b/include/mhda/chain.hpp @@ -55,7 +55,8 @@ class chain { // from_nss parses the chain-domain components ("nt", "ci" and the // optional "ct" metadata) from the given NSS string. Other components are - // tolerated and ignored, so a full address NSS is valid input. + // tolerated and ignored, so a full address NSS is valid input. Surrounding + // ASCII whitespace is trimmed; whitespace inside the NSS is refused. static chain from_nss(std::string_view nss); // set_network / set_chain_id validate like the constructor and throw diff --git a/include/mhda/parser.hpp b/include/mhda/parser.hpp index b445081..72bd413 100644 --- a/include/mhda/parser.hpp +++ b/include/mhda/parser.hpp @@ -23,7 +23,8 @@ address parse_urn(std::string_view src); address parse_urn_strict(std::string_view src); // parse_nss parses an MHDA namespace-specific string (the substring after -// "urn:mhda:"). The "nt" component is required. +// "urn:mhda:"). The "nt" component is required. Surrounding ASCII whitespace +// is trimmed; whitespace inside the NSS is refused. address parse_nss(std::string_view nss); } // namespace mhda diff --git a/src/chain.cpp b/src/chain.cpp index e8a6fbf..53c30bf 100644 --- a/src/chain.cpp +++ b/src/chain.cpp @@ -84,7 +84,7 @@ void chain::set_chain_id(chain_id ci) { } chain chain::from_nss(std::string_view nss) { - auto components = detail::parse_nss_map(nss); + auto components = detail::parse_nss_map(detail::trim(nss)); if (components.find(std::string{detail::comp_network_type}) == components.end()) { throw parse_error(error_code::missing_network_type); } diff --git a/src/detail.hpp b/src/detail.hpp index d67122a..e5800f7 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -31,6 +31,13 @@ inline std::string_view trim(std::string_view s) noexcept { return s.substr(i, j - i); } +// trim_right removes trailing ASCII whitespace only. +inline std::string_view trim_right(std::string_view s) noexcept { + std::size_t j = s.size(); + while (j > 0 && is_ascii_space(s[j - 1])) --j; + return s.substr(0, j); +} + // to_lower returns an ASCII-lowercased copy of s. inline std::string to_lower(std::string_view s) { std::string out; diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index f1ee3fe..bd882b2 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -57,11 +57,9 @@ std::unordered_map parse_nss_map(std::string_view nss) std::string{"non-ASCII or control byte in component key "} + quote(key)); } } - // RFC 8141 NSS does not permit unescaped whitespace; trim ASCII - // whitespace so any trailing space (e.g. from "ci:0 #frag" where - // strip_rqf leaves the space) does not leak into the canonical form - // and break round-trip. - auto value = trim(parts[i + 1]); + // Nothing is trimmed: the caller removes whitespace around the whole + // NSS, and whitespace around a key or value is malformed input. + const auto value = parts[i + 1]; if (value.empty()) { throw parse_error(error_code::invalid_nss, std::string{"empty value for "} + quote(key)); diff --git a/src/nss_parser.hpp b/src/nss_parser.hpp index 69fbca9..fa07347 100644 --- a/src/nss_parser.hpp +++ b/src/nss_parser.hpp @@ -41,8 +41,8 @@ bool is_known_component(std::string_view key) noexcept; // an empty key or value, a key that differs from a known key only by case // (it would drop the component silently), a duplicate known key, and a key // or value with anything outside printable ASCII (0x21–0x7E: control bytes, -// whitespace of any kind, non-ASCII bytes). Values are ASCII-trimmed before -// the checks. '?' and '#' anywhere in the NSS throw parse_error(invalid_nss): +// whitespace of any kind, non-ASCII bytes). Nothing is trimmed: the caller +// removes whitespace around the whole NSS. '?' and '#' anywhere in the NSS throw parse_error(invalid_nss): // parse_urn strips the RFC 8141 r/q/f components first, and an NSS parsed on // its own must not carry them. Mirrors go-mhda's parseNSS. // diff --git a/src/parser.cpp b/src/parser.cpp index 5cb2cb1..2e8921e 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -33,6 +33,15 @@ address parse_address_from_components(const std::unordered_map> accepted = { + {" urn:mhda:nt:evm:ci:1\t", "urn:mhda:nt:evm:ci:1"}, + {"urn:mhda:nt:evm:ci:0 #frag", "urn:mhda:nt:evm:ci:0"}, + {"urn:mhda:nt:evm:ci:1 ?=q", "urn:mhda:nt:evm:ci:1"}, + }; + for (const auto& a : accepted) { + EXPECT_EQ(parse_urn(a.first).str(), a.second); + } + EXPECT_NO_THROW(parse_nss(" nt:evm:ci:1 ")); + EXPECT_NO_THROW(chain::from_nss("\tnt:evm:ci:1 ")); + EXPECT_THROW_CODE(chain::from_nss("nt:evm:ci: 1"), error_code::invalid_nss); +} From bdf92f26bd2e8af9a631eba6df6b1cf43efb8e7c Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:05:21 +0300 Subject: [PATCH 20/21] Restrict NSS keys and values to the RFC 3986 byte set Keys and values accepted any printable ASCII, so '"', '<', '>', '\', '^', '`', '{', '|', '}', '[', ']' and a raw '%' passed and were emitted in URNs that RFC 8141 does not allow, although SPEC section 1.5 requires the NSS production (RFC 3986 pchar and "/"). detail::nss_byte now admits letters, digits and -._~!$&'()*+,;=@/ - pchar and "/" without ':' (the separator) and '%' (percent-encoding is not supported, and %41 would be a second spelling of "A"). parse_nss_map uses it for keys and values (invalid_nss), validate_free_form_value for ap/as/wt/wi and the chain id (invalid_value). The shared URN table is again identical to go-mhda's. Mirrors go-mhda. --- CHANGELOG.md | 9 +++++++++ SPEC.md | 35 +++++++++++++++++++--------------- src/detail.cpp | 3 +-- src/detail.hpp | 24 +++++++++++++++++++---- src/nss_parser.cpp | 10 ++++------ tests/data/urn_conformance.txt | 21 ++++++++++++++++++++ tests/test_hardening.cpp | 11 +++++++++++ 7 files changed, 86 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e97344..7352e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,15 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. stripped r/q/f component (`ci:0 #frag` still parses); around a key or value it throws `parse_error(invalid_nss)`. Mirrors go-mhda. +- **NSS bytes follow RFC 3986.** Keys and values accepted any printable + ASCII, so `"`, `<`, `>`, `\`, `^`, `` ` ``, `{`, `|`, `}`, `[`, `]` and a + raw `%` passed and were emitted in URNs that RFC 8141 does not allow. A + key or value is now made of letters, digits and `-._~!$&'()*+,;=@/` + only (RFC 3986 pchar and `/`, without `:` and `%`); anything else throws + `invalid_nss` when parsed and `invalid_value` from the setters and the + `chain` constructor. `%` is refused because percent-encoding is not + supported and `%41` would be a second spelling of `A`. Mirrors go-mhda. + ### Fixed - **Embedding mhda leaves the parent build alone.** Added with diff --git a/SPEC.md b/SPEC.md index e7c0ee3..80e75a9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -107,17 +107,21 @@ truncate the URN emitted from it on the next parse. ### 1.5 Charset -NSS values must consist of ASCII characters allowed by RFC 8141 NSS production -(`pchar / "/"` per RFC 3986). The reference implementation enforces printable -ASCII (0x21–0x7E) for every key and value: control bytes, whitespace of any -kind and non-ASCII bytes are rejected. ASCII whitespace is trimmed only around -the whole URN (or around an NSS or chain key parsed on its own), never around -a key or value inside it; a Unicode space is malformed input, never decoration -to strip. The setters and the `...FromString` helpers follow the same rule: -they trim ASCII whitespace and fold ASCII case only, so a Unicode letter that -lowercases to an ASCII one (the Kelvin sign `K`) is not another spelling. -Percent-encoding is not implemented; if a value needs to contain `:` -(currently no in-tree value does), percent-encoding support must be added. +NSS keys and values must consist of ASCII characters allowed by RFC 8141 NSS +production (`pchar / "/"` per RFC 3986): letters, digits and +`-._~!$&'()*+,;=@/`. `:` separates components and is never part of a value. +`%` is rejected too: percent-encoding is not implemented, and a raw `%41` +would be a second spelling of `A` (if a value ever needs `:`, percent-encoding +support must be added first). Everything else — whitespace, control bytes, +non-ASCII bytes and the printable ASCII outside that set +(``"<>\^`{|}[]?#``) — is rejected with `ErrInvalidNSS`. + +ASCII whitespace is trimmed only around the whole URN (or around an NSS or +chain key parsed on its own), never around a key or value inside it; a +Unicode space is malformed input, never decoration to strip. The setters and +the `...FromString` helpers follow the same rule: they trim ASCII whitespace +and fold ASCII case only, so a Unicode letter that lowercases to an ASCII one +(the Kelvin sign `K`) is not another spelling. ### 1.6 Wallet domain @@ -135,9 +139,10 @@ When set, the wallet domain participates in the serialised NSS (and therefore in the NSS hashes, §9); it is never part of the chain key. Because these values are typically client-supplied, the setters validate them -against NSS-corrupting characters: a value may not contain `:` (component -injection on re-parse), `?` or `#` (RFC 8141 r/q/f truncation), or -whitespace. The same rule applies to the free-form `ap` / `as` values. +against the §1.5 byte set and return `ErrInvalidValue` otherwise: in +particular a value may not contain `:` (component injection on re-parse), +`?` or `#` (RFC 8141 r/q/f truncation), or whitespace. The same rule applies +to the free-form `ap` / `as` values and to the chain id. ## 2. Network Catalogue @@ -443,7 +448,7 @@ text. | `ErrMissingChainID` | `ci` absent | | `ErrCoinTypeInChainKey` | `ChainFromKey` input carries `ct` (pre-1.1 key format) | | `ErrInvalidChainKey` | `ChainFromKey` input is not the canonical identity form | -| `ErrInvalidValue` | Free-form or chain id value with `:`/`?`/`#`/whitespace | +| `ErrInvalidValue` | Free-form or chain id value outside the §1.5 byte set | | `ErrInvalidDerivationType` | `dt` value not registered | | `ErrInvalidDerivationPath` | `dp` does not fit `dt` (or no `dt`), or an index >= 2^31 | | `ErrInvalidAlgorithm` | `aa` value not registered | diff --git a/src/detail.cpp b/src/detail.cpp index 1f2e60e..6aa4091 100644 --- a/src/detail.cpp +++ b/src/detail.cpp @@ -83,8 +83,7 @@ std::string quote(std::string_view s) { void validate_free_form_value(std::string_view component, std::string_view v) { for (char c : v) { - const auto b = static_cast(c); - if (b < 0x21 || b > 0x7e || c == ':' || c == '?' || c == '#') { + if (!nss_byte(c)) { throw parse_error(error_code::invalid_value, quote(v) + " for " + quote(component)); } diff --git a/src/detail.hpp b/src/detail.hpp index e5800f7..fbd9ba5 100644 --- a/src/detail.hpp +++ b/src/detail.hpp @@ -31,6 +31,22 @@ inline std::string_view trim(std::string_view s) noexcept { return s.substr(i, j - i); } +// nss_byte reports whether c may appear in an NSS key or value (SPEC §1.5): +// an RFC 3986 pchar or "/" - letters, digits and -._~!$&'()*+,;=@/ - except +// ':' (the component separator) and '%' (percent-encoding is not supported, +// and a raw "%41" would be a second spelling of "A"). Whitespace, control +// bytes, non-ASCII bytes and the printable ASCII outside pchar cannot appear +// in a conforming URN. Mirrors go-mhda's nssByte. +inline bool nss_byte(char c) noexcept { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + return true; + } + for (char ok : std::string_view{"-._~!$&'()*+,;=@/"}) { + if (c == ok) return true; + } + return false; +} + // trim_right removes trailing ASCII whitespace only. inline std::string_view trim_right(std::string_view s) noexcept { std::size_t j = s.size(); @@ -106,11 +122,11 @@ bool parse_uint32(std::string_view s, std::uint32_t& out) noexcept; std::string quote(std::string_view s); // validate_free_form_value guards a value written verbatim into the NSS -// (ap/as/wt/wi and the chain id) against characters that would corrupt it: -// the ':' component separator would inject foreign components on re-parse, +// (ap/as/wt/wi and the chain id) with the NSS byte set (see nss_byte): the +// ':' component separator would inject foreign components on re-parse, // '?' / '#' would truncate the URN at the RFC 8141 r/q/f delimiters, and -// anything outside printable ASCII (whitespace of any kind, control bytes, -// Unicode) cannot appear in a conforming NSS at all. Throws +// whitespace, control bytes, Unicode and the rest of the printable ASCII +// outside RFC 3986 pchar cannot appear in a conforming NSS at all. Throws // parse_error(invalid_value). Mirrors the Go reference's // validateFreeFormValue. void validate_free_form_value(std::string_view component, std::string_view v); diff --git a/src/nss_parser.cpp b/src/nss_parser.cpp index bd882b2..e129fa7 100644 --- a/src/nss_parser.cpp +++ b/src/nss_parser.cpp @@ -51,10 +51,9 @@ std::unordered_map parse_nss_map(std::string_view nss) throw parse_error(error_code::invalid_nss, "empty component key"); } for (char c : key) { - const auto b = static_cast(c); - if (b < 0x21 || b > 0x7e) { + if (!nss_byte(c)) { throw parse_error(error_code::invalid_nss, - std::string{"non-ASCII or control byte in component key "} + quote(key)); + std::string{"byte not allowed in component key "} + quote(key)); } } // Nothing is trimmed: the caller removes whitespace around the whole @@ -68,10 +67,9 @@ std::unordered_map parse_nss_map(std::string_view nss) // §1.5) — interior whitespace, control bytes and Unicode spaces are // all malformed input, never silently normalised. for (char c : value) { - const auto b = static_cast(c); - if (b < 0x21 || b > 0x7e) { + if (!nss_byte(c)) { throw parse_error(error_code::invalid_nss, - std::string{"non-ASCII or control byte in value for "} + quote(key)); + std::string{"byte not allowed in value for "} + quote(key)); } } if (!is_known_component(key)) { diff --git a/tests/data/urn_conformance.txt b/tests/data/urn_conformance.txt index 278aff0..3212e3d 100644 --- a/tests/data/urn_conformance.txt +++ b/tests/data/urn_conformance.txt @@ -122,3 +122,24 @@ accept strict urn:mhda:nt:bitcoin:ci:bitcoin:dt:bip32:dp:m/0'/0/0:af:p2wpkh urn: refuse strict incompatible urn:mhda:nt:cosmos:ci:cosmoshub:dt:bip44:dp:m/44'/118'/0'/0/0 accept strict urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 urn:mhda:nt:cosmos:ci:cosmoshub:dt:cip11:dp:m/44'/118'/0'/0/0 accept strict urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 urn:mhda:nt:cosmos:ci:secret:dt:bip44:dp:m/44'/529'/0'/0/0 + +# NSS bytes are RFC 3986 pchar or "/": letters, digits and -._~!$&'()*+,;=@/, +# except ':' (the component separator) and '%' (percent-encoding is not +# supported, and a raw %41 would be a second spelling of "A"). The other +# printable ASCII bytes cannot appear in a URN at all. +refuse urn invalid_nss urn:mhda:nt:evm:ci:a"b +refuse urn invalid_nss urn:mhda:nt:evm:ci:ab +refuse urn invalid_nss urn:mhda:nt:evm:ci:a\b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a^b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a`b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a{b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a|b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a}b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a[b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a]b +refuse urn invalid_nss urn:mhda:nt:evm:ci:a%41 +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:wi:x|y +refuse urn invalid_nss urn:mhda:nt:evm:ci:1:x|y:z +accept urn urn:mhda:nt:evm:ci:a-b.c_d~e!f$g&h'i(j)k*l+m,n;o=p@q/r urn:mhda:nt:evm:ci:a-b.c_d~e!f$g&h'i(j)k*l+m,n;o=p@q/r +accept urn urn:mhda:nt:cosmos:ci:cosmoshub-4:ap:X-avax:wi:c0a8f2d4-3b6e urn:mhda:nt:cosmos:ci:cosmoshub-4:ap:X-avax:wi:c0a8f2d4-3b6e diff --git a/tests/test_hardening.cpp b/tests/test_hardening.cpp index f617c56..8bffa88 100644 --- a/tests/test_hardening.cpp +++ b/tests/test_hardening.cpp @@ -311,3 +311,14 @@ TEST_CASE("no whitespace inside the NSS") { EXPECT_NO_THROW(chain::from_nss("\tnt:evm:ci:1 ")); EXPECT_THROW_CODE(chain::from_nss("nt:evm:ci: 1"), error_code::invalid_nss); } + +// Values set in code obey the same RFC 3986 byte set as parsed ones. +// Mirrors go-mhda's TestFreeFormValuesUseTheNSSCharset. +TEST_CASE("free-form values use the NSS byte set") { + auto a = parse_urn("urn:mhda:nt:evm:ci:1"); + for (const char* v : {"x|y", "a%41", "", "a\"b", "a{b}", "a\\b"}) { + EXPECT_THROW_CODE(a.set_wallet_id(v), error_code::invalid_value); + EXPECT_THROW_CODE((chain{network_type::ethereum_vm, v}), error_code::invalid_value); + } + EXPECT_NO_THROW(a.set_wallet_id("c0a8f2d4-3b6e_x.y~z@w")); +} From 0b78f1898908ec70007421bf3772adcde3b1fbd8 Mon Sep 17 00:00:00 2001 From: CenSync Date: Mon, 5 Oct 2026 13:06:04 +0300 Subject: [PATCH 21/21] Document EVM chain id spellings; regroup the 1.2.0 changelog ci:1, ci:0x1 and ci:01 are three chain keys and hashes for one chain. MHDA keeps the chain id opaque; SPEC.md now says so and asks a producer to keep to one spelling (the EIP-155 decimal form is the usual one), and carries go-mhda's codec note, as in go-mhda. The 1.2.0 changelog had grown entry by entry; it is regrouped into Changed (inputs refused now), Fixed, Documentation and Tests, and the README status and test counts (160 cases) are brought up to date. --- CHANGELOG.md | 153 ++++++++++++++++++++++++++------------------------- README.md | 13 +++-- SPEC.md | 9 ++- 3 files changed, 92 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7352e23..9a9ae1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.2.0] — 2026-10-05 -URNs accepted by 1.1 are refused now: a derivation path with a level index -of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. +URNs and values accepted by 1.1 are refused now: see Changed. Code that +builds addresses gets stricter constructors and setters. Mirrors go-mhda +1.2.0. ### Changed @@ -28,7 +29,15 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. `urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/4294967295`. The largest index is `2147483647`. The `ct` component is not a path level and keeps its 32-bit range. - +- **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, + the fixed coin of `cip11` / `cip1852` / `zip32` and the `0`/`1` charge of + `bip32` and the BIP-44 family are literals in the Go reference's grammar; + the port compared their values only and so accepted a leading zero that + Go refuses (`m/044'/60'/0'/0/0`, `m/44'/60'/0'/00/0`, + `m/1852'/01815'/0'/0/0`). These now throw + `parse_error(invalid_derivation_path)`. Leading zeros in a variable level + (`m/44'/060'/0'/0/0`) are still accepted, as in Go, and dropped in the + canonical form. - **Programmatic values are validated like parsed input.** The network type, the chain id and the derivation type are written verbatim into every URN, and nothing checked them when set in code: a chain id @@ -66,16 +75,6 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. - **A `slip10` path has at most 255 levels**; a deeper one throws `parse_error(invalid_derivation_path)`. BIP-32 serialises a key's depth in one byte, so no wallet can represent a deeper key. - -### Documentation - -- SPEC.md states that an explicit `dt:root` is folded away (root is the - default and has no path, so a root address has one canonical form and - one hash); the Algorand and TON notes no longer call `dt:root` the - canonical form. It also lists ZIP-32 as a known limitation: `zip32` - parses, but no network registers it, so strict parsing refuses it. Both - behaviours are unchanged and now pinned by tests. - - **Strict validation refuses curve, purpose and format combinations no wallet can derive.** `validate` checked the algorithm, the format and the derivation type each on its own, so it accepted SLIP-10 ed25519 paths @@ -92,14 +91,12 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. p2wpkh or bech32, `bip86` p2tr or bech32m); and Cosmos `bip44` with coin `118'`. A Bitcoin URN without `af` stays valid; SPEC.md no longer claims strict mode requires it. Lenient parsing is unchanged. Mirrors go-mhda. - - **No whitespace inside the NSS.** Each value was trimmed, so `urn:mhda:nt:evm:ci: 1 :dt: bip44 :...` parsed like the URN without the spaces, against SPEC §6.1. ASCII whitespace is now trimmed only around the whole URN, around an NSS or chain key parsed on its own, and before a stripped r/q/f component (`ci:0 #frag` still parses); around a key or value it throws `parse_error(invalid_nss)`. Mirrors go-mhda. - - **NSS bytes follow RFC 3986.** Keys and values accepted any printable ASCII, so `"`, `<`, `>`, `\`, `^`, `` ` ``, `{`, `|`, `}`, `[`, `]` and a raw `%` passed and were emitted in URNs that RFC 8141 does not allow. A @@ -111,34 +108,27 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. ### Fixed -- **Embedding mhda leaves the parent build alone.** Added with - `add_subdirectory` or FetchContent, mhda forced `CMAKE_BUILD_TYPE=Release` - into the cache of a parent that had none (so the parent's own code was - built with `-O3 -DNDEBUG` and its asserts off), built its tests and - examples into the parent and added its install rules to the parent's - install. These now apply only to a top-level build; `MHDA_BUILD_TESTS`, - `MHDA_BUILD_EXAMPLES` and the new `MHDA_INSTALL` default to it. -- **`BUILD_SHARED_LIBS=ON` produces a usable library.** The hidden-symbol - preset, without export macros, left `libmhda.so` with no mhda symbols - and every consumer failed to link. A shared mhda now exports its symbols - (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. -- **Error messages escape the input they quote.** `what()` copied the - caller's bytes verbatim, so a newline or an ANSI escape in a - client-supplied value (`set_wallet_id("x\n[INFO] ok")`) forged or - coloured lines in any log that recorded the exception. Quoted input is - now escaped like Go's `%q` (`\"`, `\\`, `\n`, `\r`, `\t`, `\xNN` for - other control and non-ASCII bytes), so `what()` is one line of printable - ASCII. -- **The headers compile under ``.** Its `near` macro (empty, from - `minwindef.h`) turned `coins::near` into a syntax error in any - translation unit that included `` first. `coin_type.hpp` now - sets the macro aside for its list and restores it, and the new alias - `coins::near_protocol` (after `network_type::near_protocol`) stays usable - while the macro is in effect. -- **The installed package is complete.** `mhda::mhda` now requires - `cxx_std_17` publicly, so a consumer asking for C++14 is raised to C++17 - instead of failing on `std::optional`, and the install include path - follows `CMAKE_INSTALL_INCLUDEDIR`. +- **`charge_type` is 32 bits wide (was `std::uint8_t`).** The CIP-11 charge + and the CIP-1852 role accept any level index, but the parsed value was + truncated to a byte: `m/1852'/1815'/0'/256/0` parsed as role 0, so it + named the role-0 key, re-serialised as `m/1852'/1815'/0'/0/0`, and + `levels()` returned the truncated value. The full value is now kept in + `charge()`, `levels()` and `str()`. This changes the ABI of + `derivation_path` (its constructor, `charge()` and its layout); rebuild + dependants. +- **Static initialisation and destruction order.** The named constants + (`network_type::bitcoin`, `algorithm::secp256k1`, `format::hex`, + `derivation_type::root`, ...) were defined in the library's `.cpp` files + and built at start-up in an unspecified order relative to the consumer's + globals, and the lookup tables copied them on first use. A consumer + global that used one (a built-in chain table, a URN validated up front) + could run first: it saw empty values and left the tables wrong for the + whole process (`parse_urn_strict` then refused every network as + unknown). A global destructor that parsed at exit read destroyed tables + and crashed. The constants are now inline variables defined in the + public headers, so they are initialised before any global defined after + the include, and the lookup tables are allocated once and never + destroyed. - **No stale or mixed derivation state.** `set_type` and `address::set_derivation_type` changed the type and kept the old path, so a bip44 address switched to zip32 serialised as @@ -163,37 +153,46 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. throw `parse_error(invalid_derivation_path)` otherwise. Two boundary tests that pinned the old results now expect the refusal. Mirrors go-mhda. -- **Static initialisation and destruction order.** The named constants - (`network_type::bitcoin`, `algorithm::secp256k1`, `format::hex`, - `derivation_type::root`, ...) were defined in the library's `.cpp` files - and built at start-up in an unspecified order relative to the consumer's - globals, and the lookup tables copied them on first use. A consumer - global that used one (a built-in chain table, a URN validated up front) - could run first: it saw empty values and left the tables wrong for the - whole process (`parse_urn_strict` then refused every network as - unknown). A global destructor that parsed at exit read destroyed tables - and crashed. The constants are now inline variables defined in the - public headers, so they are initialised before any global defined after - the include, and the lookup tables are allocated once and never - destroyed. -- **Fixed path levels are spelled exactly**, as in go-mhda. The purpose, - the fixed coin of `cip11` / `cip1852` / `zip32` and the `0`/`1` charge of - `bip32` and the BIP-44 family are literals in the Go reference's grammar; - the port compared their values only and so accepted a leading zero that - Go refuses (`m/044'/60'/0'/0/0`, `m/44'/60'/0'/00/0`, - `m/1852'/01815'/0'/0/0`). These now throw - `parse_error(invalid_derivation_path)`. Leading zeros in a variable level - (`m/44'/060'/0'/0/0`) are still accepted, as in Go, and dropped in the - canonical form. +- **Error messages escape the input they quote.** `what()` copied the + caller's bytes verbatim, so a newline or an ANSI escape in a + client-supplied value (`set_wallet_id("x\n[INFO] ok")`) forged or + coloured lines in any log that recorded the exception. Quoted input is + now escaped like Go's `%q` (`\"`, `\\`, `\n`, `\r`, `\t`, `\xNN` for + other control and non-ASCII bytes), so `what()` is one line of printable + ASCII. +- **The headers compile under ``.** Its `near` macro (empty, from + `minwindef.h`) turned `coins::near` into a syntax error in any + translation unit that included `` first. `coin_type.hpp` now + sets the macro aside for its list and restores it, and the new alias + `coins::near_protocol` (after `network_type::near_protocol`) stays usable + while the macro is in effect. +- **Embedding mhda leaves the parent build alone.** Added with + `add_subdirectory` or FetchContent, mhda forced `CMAKE_BUILD_TYPE=Release` + into the cache of a parent that had none (so the parent's own code was + built with `-O3 -DNDEBUG` and its asserts off), built its tests and + examples into the parent and added its install rules to the parent's + install. These now apply only to a top-level build; `MHDA_BUILD_TESTS`, + `MHDA_BUILD_EXAMPLES` and the new `MHDA_INSTALL` default to it. +- **`BUILD_SHARED_LIBS=ON` produces a usable library.** The hidden-symbol + preset, without export macros, left `libmhda.so` with no mhda symbols + and every consumer failed to link. A shared mhda now exports its symbols + (`WINDOWS_EXPORT_ALL_SYMBOLS` for a DLL); a static one keeps them hidden. +- **The installed package is complete.** `mhda::mhda` now requires + `cxx_std_17` publicly, so a consumer asking for C++14 is raised to C++17 + instead of failing on `std::optional`, and the install include path + follows `CMAKE_INSTALL_INCLUDEDIR`. -- **`charge_type` is 32 bits wide (was `std::uint8_t`).** The CIP-11 charge - and the CIP-1852 role accept any level index, but the parsed value was - truncated to a byte: `m/1852'/1815'/0'/256/0` parsed as role 0, so it - named the role-0 key, re-serialised as `m/1852'/1815'/0'/0/0`, and - `levels()` returned the truncated value. The full value is now kept in - `charge()`, `levels()` and `str()`. This changes the ABI of - `derivation_path` (its constructor, `charge()` and its layout); rebuild - dependants. +### Documentation + +- SPEC.md states that an explicit `dt:root` is folded away (root is the + default and has no path, so a root address has one canonical form and + one hash); the Algorand and TON notes no longer call `dt:root` the + canonical form. It also lists ZIP-32 as a known limitation: `zip32` + parses, but no network registers it, so strict parsing refuses it. Both + behaviours are unchanged and now pinned by tests. +- SPEC.md notes that an EVM chain id is opaque: `ci:1`, `ci:0x1` and + `ci:01` are three chain keys for one chain, and a producer must keep to + one spelling. ### Tests @@ -206,12 +205,14 @@ of 2^31 or more no longer parses. Mirrors go-mhda 1.2.0. go-mhda, run through `parse_urn` / `parse_nss` with the expected error code, for the parser changes above. - `tests/test_static_init.cpp` uses the library from a global constructor - and a global destructor; hash digests are pinned at the SHA block and - padding boundaries (55 to 1000 bytes). + and a global destructor, `tests/test_state.cpp` covers construction, + re-parsing and type changes, and `tests/test_windows_macros.cpp` compiles + the headers under the `` macros; hash digests are pinned at + the SHA block and padding boundaries (55 to 1000 bytes). - CI adds an ASan/UBSan job and a `-Werror` job with the README's warning set, a shared-library job, and a packaging job that builds the `find_package` and `add_subdirectory` consumers in `tests/cmake/`; it - runs with read-only permissions. 152 test cases total. + runs with read-only permissions. 160 test cases total. ## [1.1.0] — 2026-07-04 diff --git a/README.md b/README.md index 03e30a8..e52b0e5 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,16 @@ urn:mhda:nt:evm:ci:1:dt:bip44:dp:m/44'/60'/0'/0/0:wt:web3:wi:5f2a8c31 - Version: **1.2.0** - Standard: **C++17**, no external runtime dependencies -- Tests: **152** unit + fuzz-equivalent stress cases (≈11 000 randomised +- Tests: **160** unit + fuzz-equivalent stress cases (≈11 000 randomised iterations), passing under `-fsanitize=address,undefined,leak` - Compilers verified: GCC 11.4 (Ubuntu 22.04), Clang 14 (when libstdc++ is - available); the CI matrix runs Linux + macOS, Release + Debug, plus an - ASan/UBSan job and a `-Werror` job with the warning set below + available); the CI matrix runs Linux + macOS, Release + Debug, plus + ASan/UBSan, `-Werror` (the warning set below), shared-library and + packaging (`find_package` / `add_subdirectory`) jobs - Warning policy: clean under `-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Werror` - Binary stability is not yet guaranteed across minor versions; 1.2.0 - refuses path indices of 2^31 or more and widens `charge_type` (see + tightens the URN grammar and strict validation and changes the ABI (see [CHANGELOG.md](./CHANGELOG.md)) ## Building @@ -169,6 +170,7 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | `mhda.ParseNSS` | `mhda::parse_nss` | | `mhda.ChainFromKey` / `FromNSS` | `mhda::chain::from_key` / `from_nss` | | `mhda.NewChain(nt, ci)` | `mhda::chain{nt, ci}` | +| `Chain.SetNetworkType` / `SetChainId` | `chain::set_network` / `set_chain_id` | | `Chain.SetCoinType` / `ClearCoinType` | `chain::set_coin` / `chain::clear_coin` | | `Chain.CoinType` + `HasCoinType` | `chain::coin` (`std::optional`) | | `mhda.ParseDerivationPath` | `mhda::derivation_path::parse` | @@ -176,6 +178,7 @@ A runnable version is in [`examples/basic.cpp`](./examples/basic.cpp). | `Address.String()` / `NSS()` | `address::str` / `address::nss` | | `Address.WalletType` / `WalletId` | `address::wallet_type` / `wallet_id` | | `Address.SetWalletType` / `SetWalletId` | `address::set_wallet_type` / `set_wallet_id` | +| `Address.SetDerivation(dt, dp)` | `address::set_derivation(dt, dp)` | | `Address.MarshalText` | `address::marshal_text` | | `Address.UnmarshalText` | `address::unmarshal_text` | | `Address.Hash` / `Hash256` | `address::hash` / `hash256` | @@ -227,7 +230,7 @@ Mirrors the [SPEC §8](./SPEC.md#8-concurrency) contract. ## Testing & validation -- 152 unit + fuzz-equivalent test cases. +- 160 unit + fuzz-equivalent test cases. - Fuzz harness runs ≈11 000 randomised mutations of the historical Go-fuzz seed corpus per execution (URN, NSS and derivation-path entry points). Contracts verified: no exception other than `parse_error`/`std::invalid_argument`, diff --git a/SPEC.md b/SPEC.md index 80e75a9..d06b3ad 100644 --- a/SPEC.md +++ b/SPEC.md @@ -189,7 +189,10 @@ strict validation: `bip44` p2pkh, `bip49` p2sh, `bip84` p2wpkh or bech32, #### Ethereum and EVM clones (`evm`) Single canonical format (hex). Chain ID typically a numeric chain ID such as -`1`, `0xa86a`, `0x10`. +`1`, `0xa86a`, `0x10`. The chain id is opaque: MHDA does not canonicalise +it, so `ci:1`, `ci:0x1` and `ci:01` are three different chain keys (and +hashes) for one chain. Chain keys compare as strings; a producer must keep to +one spelling for a chain (the EIP-155 decimal form is the usual choice). #### Avalanche (`avalanche`) C-Chain uses hex (EVM-compatible); X/P-Chain use bech32 with HRPs `X-avax`, @@ -548,7 +551,9 @@ This automatically provides: - Any framework that consults `TextMarshaler` for value serialisation. `MarshalText` returns the canonical URN form. `UnmarshalText` accepts any -valid input (lenient mode). +valid input (lenient mode). `MarshalText` has a value receiver, so an +`Address` held by value (a struct field, a map value) encodes as its URN as +well; a nil `*Address` is encoded by the codec itself (JSON `null`). A `database/sql` adapter is not currently provided; callers can wrap `MarshalText`/`UnmarshalText` in their own `driver.Valuer` / `sql.Scanner`.