diff options
| -rw-r--r-- | docs/MONZERO_ASSETS_V1_SPEC.md | 7 | ||||
| -rw-r--r-- | docs/MONZERO_PHASE0_STABILIZATION.md | 1 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.cpp | 89 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.h | 1 | ||||
| -rw-r--r-- | tests/unit_tests/asset_types.cpp | 62 |
5 files changed, 160 insertions, 0 deletions
diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md index 0da86ae83..d41dc9b7c 100644 --- a/docs/MONZERO_ASSETS_V1_SPEC.md +++ b/docs/MONZERO_ASSETS_V1_SPEC.md @@ -128,6 +128,13 @@ on-chain issuance, whether it can rotate, and how it is permanently closed. Until that complete path exists, wallets must not display a collection as verified. +The prototype now includes a strict decoder for this canonical descriptor. It +rejects truncated inputs at every byte boundary, unknown network UUIDs, +unsupported versions and classes, oversized references, embedded NUL bytes, +trailing bytes, mismatched lengths, and any encoding that does not reproduce +the canonical byte sequence exactly. This parser is still not connected to +transaction or block deserialization. + ### 4.2 Inactive registry and reorganisation model The prototype includes an in-memory reference registry, disconnected from the diff --git a/docs/MONZERO_PHASE0_STABILIZATION.md b/docs/MONZERO_PHASE0_STABILIZATION.md index 2a42c8ca6..3ebf09d99 100644 --- a/docs/MONZERO_PHASE0_STABILIZATION.md +++ b/docs/MONZERO_PHASE0_STABILIZATION.md @@ -127,6 +127,7 @@ isolated disposable development network may be used after cryptographic review. - [x] Domain-separated issuer authorization signatures. - [x] Domain-separated collection-membership authorization signatures. - [x] Inactive authenticated issuance registry with deterministic reorg rollback. +- [x] Strict canonical issuance-descriptor decoder with byte-boundary truncation tests. - [x] External cryptographic-review brief and mandatory threat cases. - [ ] Reviewed confidential per-asset commitment and range-proof construction. - [ ] Versioned asset transaction serialization with strict parser limits. diff --git a/src/cryptonote_basic/asset_types.cpp b/src/cryptonote_basic/asset_types.cpp index faac669d0..927650b5a 100644 --- a/src/cryptonote_basic/asset_types.cpp +++ b/src/cryptonote_basic/asset_types.cpp @@ -5,6 +5,8 @@ #include <limits> #include <map> +#include <boost/uuid/uuid.hpp> + #include "crypto/hash.h" namespace cryptonote @@ -42,6 +44,36 @@ namespace assets for (unsigned shift = 0; shift < 64; shift += 8) target.push_back(static_cast<uint8_t>(value >> shift)); } + + template<typename T> + bool read_pod(const std::vector<uint8_t>& source, size_t& offset, T& value) + { + if (offset > source.size() || sizeof(value) > source.size() - offset) + return false; + std::memcpy(&value, source.data() + offset, sizeof(value)); + offset += sizeof(value); + return true; + } + + bool read_u16_le(const std::vector<uint8_t>& source, size_t& offset, uint16_t& value) + { + if (offset > source.size() || 2 > source.size() - offset) + return false; + value = static_cast<uint16_t>(source[offset]) + | static_cast<uint16_t>(source[offset + 1]) << 8; + offset += 2; + return true; + } + + bool read_u64_le(const std::vector<uint8_t>& source, size_t& offset, uint64_t& value) + { + if (offset > source.size() || 8 > source.size() - offset) + return false; + value = 0; + for (unsigned shift = 0; shift < 64; shift += 8) + value |= static_cast<uint64_t>(source[offset++]) << shift; + return true; + } } bool validate_issuance_descriptor(const issuance_descriptor& descriptor, std::string* error) @@ -102,6 +134,63 @@ namespace assets return true; } + bool decode_issuance_descriptor(const std::vector<uint8_t>& encoded, issuance_descriptor& descriptor, std::string* error) + { + constexpr size_t domain_size = sizeof(DOMAIN) - 1; + constexpr size_t fixed_size = domain_size + 1 + 16 + 1 + 32 + 32 + 8 + 1 + 32 + 32 + 2; + if (encoded.size() < fixed_size) + return fail(error, "truncated issuance descriptor"); + if (!std::equal(DOMAIN, DOMAIN + domain_size, encoded.begin())) + return fail(error, "invalid issuance descriptor domain"); + + issuance_descriptor parsed; + size_t offset = domain_size; + parsed.version = encoded[offset++]; + + boost::uuids::uuid network_id{}; + if (!read_pod(encoded, offset, network_id)) + return fail(error, "truncated issuance descriptor network"); + parsed.network = UNDEFINED; + for (const network_type candidate : {MAINNET, TESTNET, STAGENET}) + { + if (network_id == get_config(candidate).NETWORK_ID) + { + parsed.network = candidate; + break; + } + } + + parsed.type = static_cast<asset_class>(encoded[offset++]); + if (!read_pod(encoded, offset, parsed.issuer_key) + || !read_pod(encoded, offset, parsed.issuance_nonce) + || !read_u64_le(encoded, offset, parsed.atomic_supply)) + return fail(error, "truncated issuance descriptor identity"); + parsed.display_decimals = encoded[offset++]; + if (!read_pod(encoded, offset, parsed.metadata_hash) + || !read_pod(encoded, offset, parsed.collection_id)) + return fail(error, "truncated issuance descriptor metadata"); + + uint16_t reference_size = 0; + if (!read_u16_le(encoded, offset, reference_size)) + return fail(error, "truncated issuance descriptor metadata length"); + if (reference_size > MAX_METADATA_REFERENCE_BYTES) + return fail(error, "metadata reference is too long"); + if (offset > encoded.size() || reference_size != encoded.size() - offset) + return fail(error, "issuance descriptor length is not canonical"); + parsed.metadata_reference.assign( + reinterpret_cast<const char*>(encoded.data() + offset), reference_size); + + if (!validate_issuance_descriptor(parsed, error)) + return false; + + std::vector<uint8_t> canonical; + if (!encode_issuance_descriptor(parsed, canonical, error) || canonical != encoded) + return fail(error, "issuance descriptor encoding is not canonical"); + + descriptor = std::move(parsed); + return true; + } + bool derive_asset_id(const issuance_descriptor& descriptor, crypto::hash& asset_id, std::string* error) { std::vector<uint8_t> encoded; diff --git a/src/cryptonote_basic/asset_types.h b/src/cryptonote_basic/asset_types.h index e24f61576..7f2fe0c99 100644 --- a/src/cryptonote_basic/asset_types.h +++ b/src/cryptonote_basic/asset_types.h @@ -48,6 +48,7 @@ namespace assets bool validate_issuance_descriptor(const issuance_descriptor& descriptor, std::string* error = nullptr); bool encode_issuance_descriptor(const issuance_descriptor& descriptor, std::vector<uint8_t>& encoded, std::string* error = nullptr); + bool decode_issuance_descriptor(const std::vector<uint8_t>& encoded, issuance_descriptor& descriptor, std::string* error = nullptr); bool derive_asset_id(const issuance_descriptor& descriptor, crypto::hash& asset_id, std::string* error = nullptr); bool derive_issuance_authorization_hash(const issuance_descriptor& descriptor, crypto::hash& message, std::string* error = nullptr); bool verify_issuance_authorization(const issuance_descriptor& descriptor, const crypto::signature& signature, std::string* error = nullptr); diff --git a/tests/unit_tests/asset_types.cpp b/tests/unit_tests/asset_types.cpp index b2f250fc5..79f26cd29 100644 --- a/tests/unit_tests/asset_types.cpp +++ b/tests/unit_tests/asset_types.cpp @@ -55,6 +55,68 @@ TEST(asset_types, deterministic_canonical_identity) ASSERT_GT(encoded.size(), descriptor.metadata_reference.size()); } +TEST(asset_types, canonical_descriptor_round_trip) +{ + for (const auto network : {cryptonote::MAINNET, cryptonote::TESTNET, cryptonote::STAGENET}) + { + const auto original = make_descriptor(network); + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_issuance_descriptor(original, encoded)); + + cryptonote::assets::issuance_descriptor decoded; + ASSERT_TRUE(cryptonote::assets::decode_issuance_descriptor(encoded, decoded)); + EXPECT_EQ(original.version, decoded.version); + EXPECT_EQ(original.network, decoded.network); + EXPECT_EQ(original.type, decoded.type); + EXPECT_EQ(original.issuer_key, decoded.issuer_key); + EXPECT_EQ(original.issuance_nonce, decoded.issuance_nonce); + EXPECT_EQ(original.atomic_supply, decoded.atomic_supply); + EXPECT_EQ(original.display_decimals, decoded.display_decimals); + EXPECT_EQ(original.metadata_hash, decoded.metadata_hash); + EXPECT_EQ(original.collection_id, decoded.collection_id); + EXPECT_EQ(original.metadata_reference, decoded.metadata_reference); + + std::vector<uint8_t> reencoded; + ASSERT_TRUE(cryptonote::assets::encode_issuance_descriptor(decoded, reencoded)); + EXPECT_EQ(encoded, reencoded); + } +} + +TEST(asset_types, descriptor_decoder_rejects_truncation_and_noncanonical_lengths) +{ + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_issuance_descriptor( + make_descriptor(cryptonote::TESTNET), encoded)); + + cryptonote::assets::issuance_descriptor decoded; + std::string error; + for (size_t size = 0; size < encoded.size(); ++size) + { + const std::vector<uint8_t> truncated(encoded.begin(), encoded.begin() + size); + EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(truncated, decoded, &error)) + << "accepted truncated size " << size; + } + + auto trailing = encoded; + trailing.push_back(0); + EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(trailing, decoded, &error)); + + auto bad_domain = encoded; + bad_domain[0] ^= 1; + EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(bad_domain, decoded, &error)); + + auto bad_version = encoded; + bad_version[sizeof("MonzeroAssetIssuanceV2") - 1]++; + EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(bad_version, decoded, &error)); + + // The final two bytes before the reference are its little-endian length. + const size_t length_offset = encoded.size() - make_descriptor(cryptonote::TESTNET).metadata_reference.size() - 2; + auto oversized = encoded; + oversized[length_offset] = 1; + oversized[length_offset + 1] = 1; + EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(oversized, decoded, &error)); +} + TEST(asset_types, network_domain_separation) { auto mainnet = make_descriptor(cryptonote::MAINNET); |
