aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/MONZERO_ASSETS_V1_SPEC.md9
-rw-r--r--docs/MONZERO_PHASE0_STABILIZATION.md1
-rw-r--r--src/cryptonote_basic/asset_types.cpp101
-rw-r--r--src/cryptonote_basic/asset_types.h16
-rw-r--r--tests/unit_tests/asset_types.cpp67
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);