diff options
| author | Monzero Build System <builds@monzero.org> | 2026-08-15 21:57:55 +0100 |
|---|---|---|
| committer | Monzero Build System <builds@monzero.org> | 2026-08-15 21:57:55 +0100 |
| commit | 310832fe087427fc01af504821190c2482a438d7 (patch) | |
| tree | 35aea99aed4d209e7b972106da94dde6fead4bc4 | |
| parent | e933bd36cee44b42d631c1c64e9f3df027be7ab0 (diff) | |
| download | monzero-core-310832fe087427fc01af504821190c2482a438d7.tar.gz monzero-core-310832fe087427fc01af504821190c2482a438d7.tar.xz monzero-core-310832fe087427fc01af504821190c2482a438d7.zip | |
Add authenticated asset issuance payload
| -rw-r--r-- | docs/MONZERO_ASSETS_V1_SPEC.md | 9 | ||||
| -rw-r--r-- | docs/MONZERO_PHASE0_STABILIZATION.md | 1 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.cpp | 101 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_types.h | 16 | ||||
| -rw-r--r-- | tests/unit_tests/asset_types.cpp | 67 |
5 files changed, 194 insertions, 0 deletions
diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md index d41dc9b7c..a49764fe2 100644 --- a/docs/MONZERO_ASSETS_V1_SPEC.md +++ b/docs/MONZERO_ASSETS_V1_SPEC.md @@ -135,6 +135,15 @@ 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. +An additional inactive issuance-payload envelope binds the descriptor to its +issuer authorization and, when collection membership is claimed, requires an +explicit collection authorization slot. Its decoder is length-bounded, +canonical, verifies the issuer signature, rejects every truncated prefix, +rejects invalid signature flags and trailing bytes, and prevents collection +signatures from being silently omitted or attached to unrelated issuance. +Collection-controller verification still requires the future persistent +registry lookup and therefore remains part of consensus integration work. + ### 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 3ebf09d99..ec45be62b 100644 --- a/docs/MONZERO_PHASE0_STABILIZATION.md +++ b/docs/MONZERO_PHASE0_STABILIZATION.md @@ -128,6 +128,7 @@ isolated disposable development network may be used after cryptographic review. - [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] Bounded authenticated issuance-payload envelope with strict signature shape rules. - [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 927650b5a..05a8614b4 100644 --- a/src/cryptonote_basic/asset_types.cpp +++ b/src/cryptonote_basic/asset_types.cpp @@ -18,6 +18,7 @@ namespace assets constexpr char DOMAIN[] = "MonzeroAssetIssuanceV2"; constexpr char ISSUANCE_AUTHORIZATION_DOMAIN[] = "MonzeroAssetIssuanceAuthorizationV1"; constexpr char COLLECTION_MEMBERSHIP_DOMAIN[] = "MonzeroCollectionMembershipV1"; + constexpr char ISSUANCE_PAYLOAD_DOMAIN[] = "MonzeroAssetIssuancePayloadV1"; bool fail(std::string* error, const char* message) { @@ -255,6 +256,91 @@ namespace assets return true; } + bool encode_issuance_payload(const issuance_payload& payload, std::vector<uint8_t>& encoded, std::string* error) + { + if (payload.version != ISSUANCE_PAYLOAD_VERSION) + return fail(error, "unsupported issuance payload version"); + + std::vector<uint8_t> descriptor; + if (!encode_issuance_descriptor(payload.descriptor, descriptor, error)) + return false; + if (descriptor.size() > std::numeric_limits<uint16_t>::max()) + return fail(error, "issuance descriptor does not fit its bounded payload"); + if (!verify_issuance_authorization(payload.descriptor, payload.issuer_signature, error)) + return false; + + const bool claims_collection = payload.descriptor.collection_id != crypto::null_hash; + if (claims_collection != static_cast<bool>(payload.collection_signature)) + return fail(error, claims_collection + ? "collection membership signature is missing" + : "unexpected collection signature for an uncollected asset"); + + encoded.clear(); + encoded.reserve(sizeof(ISSUANCE_PAYLOAD_DOMAIN) - 1 + 1 + 2 + descriptor.size() + + sizeof(payload.issuer_signature) + 1 + + (payload.collection_signature ? sizeof(*payload.collection_signature) : 0)); + encoded.insert(encoded.end(), ISSUANCE_PAYLOAD_DOMAIN, + ISSUANCE_PAYLOAD_DOMAIN + sizeof(ISSUANCE_PAYLOAD_DOMAIN) - 1); + encoded.push_back(payload.version); + append_u16_le(encoded, static_cast<uint16_t>(descriptor.size())); + encoded.insert(encoded.end(), descriptor.begin(), descriptor.end()); + append_pod(encoded, payload.issuer_signature); + encoded.push_back(payload.collection_signature ? 1 : 0); + if (payload.collection_signature) + append_pod(encoded, *payload.collection_signature); + return true; + } + + bool decode_issuance_payload(const std::vector<uint8_t>& encoded, issuance_payload& payload, std::string* error) + { + constexpr size_t domain_size = sizeof(ISSUANCE_PAYLOAD_DOMAIN) - 1; + constexpr size_t minimum_size = domain_size + 1 + 2 + sizeof(crypto::signature) + 1; + if (encoded.size() < minimum_size) + return fail(error, "truncated issuance payload"); + if (!std::equal(ISSUANCE_PAYLOAD_DOMAIN, + ISSUANCE_PAYLOAD_DOMAIN + domain_size, encoded.begin())) + return fail(error, "invalid issuance payload domain"); + + issuance_payload parsed; + size_t offset = domain_size; + parsed.version = encoded[offset++]; + if (parsed.version != ISSUANCE_PAYLOAD_VERSION) + return fail(error, "unsupported issuance payload version"); + + uint16_t descriptor_size = 0; + if (!read_u16_le(encoded, offset, descriptor_size) + || offset > encoded.size() || descriptor_size > encoded.size() - offset) + return fail(error, "invalid issuance payload descriptor length"); + const std::vector<uint8_t> descriptor_bytes( + encoded.begin() + offset, encoded.begin() + offset + descriptor_size); + offset += descriptor_size; + if (!decode_issuance_descriptor(descriptor_bytes, parsed.descriptor, error)) + return false; + if (!read_pod(encoded, offset, parsed.issuer_signature)) + return fail(error, "truncated issuer authorization signature"); + if (offset >= encoded.size()) + return fail(error, "truncated collection signature flag"); + + const uint8_t has_collection_signature = encoded[offset++]; + if (has_collection_signature > 1) + return fail(error, "invalid collection signature flag"); + if (has_collection_signature) + { + crypto::signature signature{}; + if (!read_pod(encoded, offset, signature)) + return fail(error, "truncated collection authorization signature"); + parsed.collection_signature = signature; + } + if (offset != encoded.size()) + return fail(error, "issuance payload has trailing bytes"); + + std::vector<uint8_t> canonical; + if (!encode_issuance_payload(parsed, canonical, error) || canonical != encoded) + return fail(error, "issuance payload encoding is not canonical"); + payload = std::move(parsed); + return true; + } + bool asset_registry::apply_issuance( const issuance_descriptor& descriptor, const crypto::signature& issuer_signature, @@ -297,6 +383,21 @@ namespace assets return true; } + bool asset_registry::apply_issuance( + const issuance_payload& payload, + uint64_t height, + crypto::hash& asset_id, + std::string* error) + { + return apply_issuance( + payload.descriptor, + payload.issuer_signature, + payload.collection_signature, + height, + asset_id, + error); + } + void asset_registry::detach(uint64_t height) { for (auto it = records_.begin(); it != records_.end();) diff --git a/src/cryptonote_basic/asset_types.h b/src/cryptonote_basic/asset_types.h index 7f2fe0c99..7265ffdf2 100644 --- a/src/cryptonote_basic/asset_types.h +++ b/src/cryptonote_basic/asset_types.h @@ -60,6 +60,21 @@ namespace assets const crypto::signature& signature, std::string* error = nullptr); + constexpr uint8_t ISSUANCE_PAYLOAD_VERSION = 1; + + // Inactive, bounded wire prototype for an authenticated fixed-supply + // issuance. It is not yet a transaction field or consensus type. + struct issuance_payload + { + uint8_t version = ISSUANCE_PAYLOAD_VERSION; + issuance_descriptor descriptor; + crypto::signature issuer_signature{}; + boost::optional<crypto::signature> collection_signature; + }; + + bool encode_issuance_payload(const issuance_payload& payload, std::vector<uint8_t>& encoded, std::string* error = nullptr); + bool decode_issuance_payload(const std::vector<uint8_t>& encoded, issuance_payload& payload, std::string* error = nullptr); + struct asset_record { issuance_descriptor descriptor; @@ -79,6 +94,7 @@ namespace assets uint64_t height, crypto::hash& asset_id, std::string* error = nullptr); + bool apply_issuance(const issuance_payload& payload, uint64_t height, crypto::hash& asset_id, std::string* error = nullptr); void detach(uint64_t height); bool contains(const crypto::hash& asset_id) const; const asset_record* find(const crypto::hash& asset_id) const; diff --git a/tests/unit_tests/asset_types.cpp b/tests/unit_tests/asset_types.cpp index 79f26cd29..700f29ede 100644 --- a/tests/unit_tests/asset_types.cpp +++ b/tests/unit_tests/asset_types.cpp @@ -117,6 +117,73 @@ TEST(asset_types, descriptor_decoder_rejects_truncation_and_noncanonical_lengths EXPECT_FALSE(cryptonote::assets::decode_issuance_descriptor(oversized, decoded, &error)); } +TEST(asset_types, authenticated_issuance_payload_round_trip) +{ + crypto::public_key issuer_public{}; + crypto::secret_key issuer_secret{}; + crypto::generate_keys(issuer_public, issuer_secret); + + cryptonote::assets::issuance_payload original; + original.descriptor = make_descriptor(cryptonote::TESTNET); + original.descriptor.issuer_key = issuer_public; + original.issuer_signature = authorize(original.descriptor, issuer_public, issuer_secret); + + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_issuance_payload(original, encoded)); + cryptonote::assets::issuance_payload decoded; + ASSERT_TRUE(cryptonote::assets::decode_issuance_payload(encoded, decoded)); + EXPECT_EQ(original.descriptor.issuer_key, decoded.descriptor.issuer_key); + EXPECT_EQ(original.issuer_signature, decoded.issuer_signature); + EXPECT_FALSE(decoded.collection_signature); + + cryptonote::assets::asset_registry registry; + crypto::hash asset_id{}; + EXPECT_TRUE(registry.apply_issuance(decoded, 1, asset_id)); + EXPECT_TRUE(registry.contains(asset_id)); +} + +TEST(asset_types, issuance_payload_rejects_tampering_and_signature_shape_errors) +{ + crypto::public_key issuer_public{}; + crypto::secret_key issuer_secret{}; + crypto::generate_keys(issuer_public, issuer_secret); + + cryptonote::assets::issuance_payload payload; + payload.descriptor = make_descriptor(cryptonote::STAGENET); + payload.descriptor.issuer_key = issuer_public; + payload.issuer_signature = authorize(payload.descriptor, issuer_public, issuer_secret); + + std::vector<uint8_t> encoded; + ASSERT_TRUE(cryptonote::assets::encode_issuance_payload(payload, encoded)); + cryptonote::assets::issuance_payload 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_payload(truncated, decoded, &error)) + << "accepted truncated payload size " << size; + } + + auto tampered = encoded; + tampered[tampered.size() - 2] ^= 1; + EXPECT_FALSE(cryptonote::assets::decode_issuance_payload(tampered, decoded, &error)); + + payload.collection_signature = crypto::signature{}; + EXPECT_FALSE(cryptonote::assets::encode_issuance_payload(payload, encoded, &error)); + + auto member = make_descriptor(cryptonote::STAGENET); + member.type = cryptonote::assets::asset_class::non_fungible; + member.atomic_supply = 1; + member.display_decimals = 0; + member.issuer_key = issuer_public; + member.collection_id.data[0] = 1; + payload.descriptor = member; + payload.issuer_signature = authorize(member, issuer_public, issuer_secret); + payload.collection_signature = boost::none; + EXPECT_FALSE(cryptonote::assets::encode_issuance_payload(payload, encoded, &error)); +} + TEST(asset_types, network_domain_separation) { auto mainnet = make_descriptor(cryptonote::MAINNET); |
